瀏覽代碼

提交代码修改

zlwu 1 年之前
父節點
當前提交
97f7619bca

+ 152 - 1
docs/变更管理/code-adjust-record-2025-01-27-ai-assistant.md

@@ -420,4 +420,155 @@ disabilityLevel: '',
 **变更时间**: 2025-01-27 16:00:00  
 **变更人员**: AI Assistant  
 **变更类型**: 功能优化  
-**影响范围**: 学生花名册高级检索界面 
+**影响范围**: 学生花名册高级检索界面 
+
+# 代码调整记录 - 2025-01-27
+
+## 修改概述
+将 `stuMsgDetailDialog.vue` 界面的学生照片加载方式与 `index.vue` 界面保持一致,实现统一的异步照片加载机制。
+
+## 修改文件
+- `web/src/views/xg/xsjcxx/stuMsgSupply/stuMsgDetailDialog.vue`
+
+## 具体修改内容
+
+### 1. 模板部分修改
+- **位置**: 第100-108行
+- **修改前**: 直接使用 `form.photoUrl` 显示照片
+- **修改后**: 使用异步加载机制,包含加载状态显示
+```vue
+<!-- 修改前 -->
+<img v-if="form.photoUrl" :src="form.photoUrl" class="photo" />
+
+<!-- 修改后 -->
+<div v-if="photoLoading" class="photo-loading">
+  <el-icon class="is-loading"><Loading /></el-icon>
+  <span>加载中...</span>
+</div>
+<img v-else-if="asyncPhotoUrl" :src="asyncPhotoUrl" class="photo" />
+```
+
+### 2. 脚本部分修改
+
+#### 2.1 导入模块
+- **位置**: 第745行
+- **新增**: 导入 `watchEffect` 和 `Loading` 图标
+```typescript
+import { ref, reactive, getCurrentInstance, nextTick, watch, toRefs, watchEffect } from 'vue';
+import { Loading } from '@element-plus/icons-vue';
+```
+
+#### 2.2 响应式变量
+- **位置**: 第825-826行
+- **新增**: 照片加载相关的响应式变量
+```typescript
+const photoLoading = ref(false); // 照片加载状态
+const asyncPhotoUrl = ref(''); // 异步加载的照片URL
+```
+
+#### 2.3 照片加载方法
+- **位置**: 第975-1015行
+- **新增**: `getImageBlobUrl` 方法,通过认证请求获取图片数据
+```typescript
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  // 通过axios请求图片数据,自动带上token和clientId
+  const response = await request({
+    url: '/resource/oss/download/' + imageUrl,
+    method: 'get',
+    responseType: 'blob',
+    timeout: 300000
+  });
+  // 创建blob URL并返回
+}
+```
+
+#### 2.4 监听器
+- **位置**: 第1225-1240行
+- **新增**: `watchEffect` 监听 `form.photoPath` 变化,异步加载图片
+```typescript
+watchEffect(async () => {
+  if (form.photoPath) {
+    photoLoading.value = true;
+    try {
+      const blobUrl = await getImageBlobUrl(form.photoPath);
+      asyncPhotoUrl.value = blobUrl;
+    } catch (error) {
+      console.error('加载图片失败:', error);
+      asyncPhotoUrl.value = '';
+    } finally {
+      photoLoading.value = false;
+    }
+  } else {
+    asyncPhotoUrl.value = '';
+    photoLoading.value = false;
+  }
+});
+```
+
+### 3. 样式部分修改
+- **位置**: 第1740-1762行
+- **新增**: 照片加载状态的CSS样式
+```css
+.photo-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 150px;
+  height: 150px;
+  border: 2px dashed #d9d9d9;
+  border-radius: 6px;
+  background-color: #fafafa;
+  color: #8c8d91;
+  font-size: 14px;
+  transition: border-color 0.3s;
+}
+
+.photo-loading .el-icon {
+  font-size: 24px;
+  margin-bottom: 8px;
+  color: #409eff;
+}
+
+.photo-loading span {
+  font-size: 12px;
+}
+```
+
+## 技术实现要点
+
+### 1. 异步加载机制
+- 使用 `watchEffect` 监听 `form.photoPath` 变化
+- 通过 `getImageBlobUrl` 方法获取图片数据
+- 支持认证请求,自动携带token和clientId
+
+### 2. 加载状态管理
+- `photoLoading` 控制加载状态显示
+- `asyncPhotoUrl` 存储异步加载的照片URL
+- 加载失败时显示占位符
+
+### 3. 用户体验优化
+- 加载过程中显示加载动画和提示文字
+- 加载失败时优雅降级到占位符
+- 保持与 `index.vue` 界面一致的视觉效果
+
+## 修改效果
+1. **统一性**: 两个界面的照片加载方式完全一致
+2. **安全性**: 通过认证请求获取图片,确保访问权限
+3. **用户体验**: 提供加载状态反馈,提升用户体验
+4. **错误处理**: 完善的错误处理机制,确保界面稳定性
+
+## 测试建议
+1. 测试有照片路径时的加载效果
+2. 测试无照片路径时的显示效果
+3. 测试网络异常时的错误处理
+4. 测试加载状态的视觉反馈
+
+## 相关文件
+- 参考文件: `web/src/views/xg/xsjcxx/stuPersonalMsg/index.vue`
+- 修改文件: `web/src/views/xg/xsjcxx/stuMsgSupply/stuMsgDetailDialog.vue`
+
+---
+**修改时间**: 2025-01-27 14:30:00  
+**修改人**: AI Assistant  
+**修改类型**: 功能优化 - 照片加载方式统一 

+ 289 - 0
docs/变更管理/code-adjust-record-2025-01-27-cursor.md

@@ -891,3 +891,292 @@ watchEffect(async () => {
 **变更状态**: ✅ 已完成  
 **代码审查**: 待审查  
 **测试状态**: 待测试
+
+# 学生花名册卡片视图照片加载中状态优化
+
+**变更时间**: 2025-01-27 15:30:00  
+**变更人员**: Cursor AI  
+**变更文件**: `web/src/views/xg/xsjcxx/stuRoster/index.vue`
+
+## 变更内容
+
+### 功能优化
+在学生花名册卡片视图中,优化了学生照片加载过程中的用户体验:
+
+1. **照片加载中状态显示优化**
+   - 将原本显示在头像内部的"加载中..."文字移除
+   - 在头像底部新增独立的加载中状态提示
+   - 添加了旋转的加载图标和"加载中..."文字
+
+2. **视觉效果增强**
+   - 加载中状态文字使用蓝色主题色
+   - 添加了淡蓝色背景和边框
+   - 实现了脉冲动画效果,提升视觉吸引力
+   - 加载图标持续旋转,提供动态反馈
+
+3. **布局优化**
+   - 调整卡片头部布局为 `align-items: flex-start`,确保在有加载中文字时布局不会错乱
+   - 照片容器改为垂直布局,头像和加载文字垂直排列
+
+4. **响应式适配**
+   - 为移动端添加了专门的样式调整
+   - 移动端加载中文字更小更紧凑
+   - 确保在不同屏幕尺寸下都有良好的显示效果
+
+## 技术实现
+
+### HTML结构变更
+```vue
+<!-- 照片加载中状态文字 - 显示在头像底部 -->
+<div v-if="isPhotoLoading(item)" class="photo-loading-text">
+  <el-text type="primary" size="small">
+    <el-icon class="is-loading mr-1" size="12">
+      <Loading />
+    </el-icon>
+    加载中...
+  </el-text>
+</div>
+```
+
+### CSS样式新增
+```css
+/* 照片加载中状态文字样式 */
+.photo-loading-text {
+  margin-top: 8px;
+  text-align: center;
+  padding: 4px 8px;
+  background-color: #f0f9ff;
+  border: 1px solid #b3d8ff;
+  border-radius: 12px;
+  transition: all 0.3s ease;
+  animation: pulse 2s infinite;
+}
+
+.photo-loading-text .el-text {
+  font-weight: 500;
+  color: #409eff;
+}
+
+.photo-loading-text .el-icon {
+  animation: rotate 1s linear infinite;
+}
+
+@keyframes pulse {
+  0%, 100% {
+    opacity: 1;
+    transform: scale(1);
+  }
+  50% {
+    opacity: 0.8;
+    transform: scale(1.05);
+  }
+}
+```
+
+### 移动端响应式样式
+```css
+/* 移动端照片加载中文字样式调整 */
+.photo-loading-text {
+  margin-top: 6px;
+  padding: 3px 6px;
+  font-size: 11px;
+}
+
+.photo-loading-text .el-icon {
+  font-size: 10px !important;
+}
+```
+
+## 用户体验改进
+
+1. **更清晰的加载状态反馈**
+   - 用户能够清楚地看到照片正在加载
+   - 避免了头像内部文字显示不清晰的问题
+
+2. **更好的视觉层次**
+   - 加载状态文字独立显示,不会与头像内容混淆
+   - 使用动画效果提升了视觉吸引力
+
+3. **响应式友好**
+   - 在不同设备上都有良好的显示效果
+   - 移动端适配更加完善
+
+## 测试建议
+
+1. **功能测试**
+   - 验证照片加载过程中是否正确显示加载中状态
+   - 确认加载完成后加载中状态是否正确消失
+
+2. **视觉测试**
+   - 检查加载中动画效果是否流畅
+   - 验证在不同屏幕尺寸下的显示效果
+
+3. **性能测试**
+   - 确认新增的动画效果不会影响页面性能
+   - 验证大量学生数据时的显示效果
+
+## 相关文件
+
+- **主要文件**: `web/src/views/xg/xsjcxx/stuRoster/index.vue`
+- **相关组件**: 学生花名册卡片视图组件
+- **样式文件**: 内联样式,无需额外文件
+
+## 备注
+
+此次优化提升了学生花名册卡片视图的用户体验,特别是在照片加载过程中的状态反馈更加清晰和友好。通过将加载状态文字从头像内部移到外部,避免了视觉混乱,同时添加了动画效果提升了界面的现代感。
+
+# 学生信息审核界面照片加载功能优化
+
+**变更时间**: 2025-01-27 15:30:00  
+**变更人员**: Cursor AI  
+**变更类型**: 功能优化  
+
+## 📋 变更概述
+
+参考学生信息完善界面的照片加载方式,优化学生信息审核界面的照片加载功能,实现异步加载和更好的用户体验。
+
+## 🔧 具体变更内容
+
+### 1. 照片显示逻辑优化
+**文件**: `web/src/views/xg/xsjcxx/stuMsgAudit/stuMsgDetailDialog.vue`
+
+#### 变更前
+```vue
+<div class="photo-display">
+  <img v-if="form.photoUrl" :src="form.photoUrl" class="photo" />
+  <div v-else class="photo-placeholder">暂无照片</div>
+</div>
+```
+
+#### 变更后
+```vue
+<div class="photo-display">
+  <div v-if="photoLoading" class="photo-loading">
+    <el-icon class="is-loading"><Loading /></el-icon>
+    <span>加载中...</span>
+  </div>
+  <img v-else-if="asyncPhotoUrl" :src="asyncPhotoUrl" class="photo" />
+  <div v-else class="photo-placeholder">暂无照片</div>
+</div>
+```
+
+### 2. 新增响应式数据
+```typescript
+const photoLoading = ref(false); // 照片加载状态
+const asyncPhotoUrl = ref(''); // 异步加载的照片URL
+```
+
+### 3. 新增照片加载方法
+```typescript
+// 通过认证请求获取图片数据并转换为blob URL
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  try {
+    const response = await request({
+      url: '/resource/oss/download/' + imageUrl,
+      method: 'get',
+      responseType: 'blob',
+      timeout: 300000 // 300秒超时
+    });
+
+    const blob = response;
+    
+    if (!blob || !(blob instanceof Blob)) {
+      throw new Error('响应数据不是有效的Blob对象');
+    }
+
+    const blobUrl = URL.createObjectURL(blob);
+    return blobUrl;
+  } catch (error) {
+    return '';
+  }
+};
+```
+
+### 4. 新增照片路径监听
+```typescript
+// 监听form.photoPath变化,异步加载图片
+watchEffect(async () => {
+  if (form.photoPath) {
+    photoLoading.value = true;
+    try {
+      const blobUrl = await getImageBlobUrl(form.photoPath);
+      asyncPhotoUrl.value = blobUrl;
+    } catch (error) {
+      console.error('加载图片失败:', error);
+      asyncPhotoUrl.value = '';
+    } finally {
+      photoLoading.value = false;
+    }
+  } else {
+    asyncPhotoUrl.value = '';
+    photoLoading.value = false;
+  }
+});
+```
+
+### 5. 新增导入语句
+```typescript
+import { watchEffect } from 'vue';
+import { Loading } from '@element-plus/icons-vue';
+```
+
+### 6. 新增CSS样式
+```css
+/* 照片加载状态样式 */
+.photo-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 150px;
+  height: 150px;
+  border: 2px dashed #d9d9d9;
+  border-radius: 6px;
+  background-color: #fafafa;
+  color: #8c8d91;
+  font-size: 14px;
+  transition: border-color 0.3s;
+}
+
+.photo-loading .el-icon {
+  font-size: 24px;
+  margin-bottom: 8px;
+  color: #409eff;
+}
+
+.photo-loading span {
+  font-size: 12px;
+}
+```
+
+## 🎯 优化效果
+
+### 用户体验提升
+1. **加载状态提示**: 照片加载时显示加载动画和文字提示
+2. **异步加载**: 通过blob URL异步加载照片,避免直接暴露文件路径
+3. **错误处理**: 加载失败时优雅降级,显示占位符
+4. **认证支持**: 自动携带token和clientId进行认证请求
+
+### 技术改进
+1. **安全性**: 使用blob URL避免直接暴露服务器文件路径
+2. **性能**: 异步加载避免阻塞界面渲染
+3. **兼容性**: 支持认证的图片请求,适用于需要权限的图片资源
+4. **可维护性**: 统一的照片加载逻辑,便于后续维护
+
+## 📚 参考实现
+
+本次优化参考了 `web/src/views/xg/xsjcxx/stuMsgSupply/stuMsgDetailDialog.vue` 中的照片加载实现,确保了两个界面的一致性。
+
+## ✅ 验证要点
+
+1. 照片加载时显示加载动画
+2. 照片加载成功后正确显示
+3. 照片加载失败时显示占位符
+4. 支持认证的图片请求
+5. 界面响应正常,无控制台错误
+
+---
+
+**变更状态**: ✅ 已完成  
+**测试状态**: 🔄 待测试  
+**文档状态**: ✅ 已更新

+ 98 - 2
web/src/views/xg/xsjcxx/stuMsgAudit/stuMsgDetailDialog.vue

@@ -99,7 +99,11 @@
                   </div>
                 </template>
                 <div class="photo-display">
-                  <img v-if="form.photoUrl" :src="form.photoUrl" class="photo" />
+                  <div v-if="photoLoading" class="photo-loading">
+                    <el-icon class="is-loading"><Loading /></el-icon>
+                    <span>加载中...</span>
+                  </div>
+                  <img v-else-if="asyncPhotoUrl" :src="asyncPhotoUrl" class="photo" />
                   <div v-else class="photo-placeholder">暂无照片</div>
                 </div>
               </el-form-item>
@@ -746,8 +750,9 @@
 </template>
 
 <script setup lang="ts" name="StuMsgDetailDialog">
-import { ref, reactive, getCurrentInstance, nextTick, watch, toRefs } from 'vue';
+import { ref, reactive, getCurrentInstance, nextTick, watch, toRefs, watchEffect } from 'vue';
 import ElMessage from 'element-plus/es/components/message/index';
+import { Loading } from '@element-plus/icons-vue';
 // @ts-ignore
 import request from '@/utils/request';
 // 获取当前组件实例
@@ -819,6 +824,8 @@ const loading = ref(false);
 const dialogLoading = ref(false); // 对话框整体加载状态
 const regionOptions = ref([]); // 行政区划选项
 const dormBuildingOptions = ref([]); // 宿舍楼栋选项
+const photoLoading = ref(false); // 照片加载状态
+const asyncPhotoUrl = ref(''); // 异步加载的照片URL
 
 // 历史记录相关数据
 const historyLoading = ref(false);
@@ -964,6 +971,50 @@ const getDormBuildingTree = async () => {
   }
 };
 
+// 通过认证请求获取图片数据并转换为blob URL
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  try {
+    // 通过axios请求图片数据,这样会自动带上token和clientId
+    const response = await request({
+      url: '/resource/oss/download/' + imageUrl,
+      method: 'get',
+      responseType: 'blob',
+      timeout: 300000 // 300秒超时
+    });
+
+    // 由于axios响应拦截器对blob类型响应直接返回blob数据
+    // 这里response直接就是blob对象,而不是包含data属性的响应对象
+    const blob = response;
+
+    // 验证响应数据
+    if (!blob) {
+      throw new Error('响应数据为空');
+    }
+
+    if (!(blob instanceof Blob)) {
+      // 如果不是Blob,尝试获取实际内容用于调试
+      let actualContent = 'Unknown';
+      try {
+        if (typeof blob === 'string') {
+          actualContent = (blob as string).substring(0, 200);
+        } else if (blob && typeof blob.toString === 'function') {
+          actualContent = blob.toString().substring(0, 200);
+        }
+      } catch (e) {
+        actualContent = 'Cannot convert to string';
+      }
+
+      throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
+    }
+
+    // 创建blob URL
+    const blobUrl = URL.createObjectURL(blob);
+    return blobUrl;
+  } catch (error) {
+    return '';
+  }
+};
+
 /** 获取用户历史数据 */
 const getUserHistoryData = async (batchId: number, userId?: number) => {
   if (!batchId) {
@@ -1171,6 +1222,25 @@ const rules = reactive({
   gender: [{ required: true, message: '性别不能为空', trigger: 'blur' }]
 });
 
+// 监听form.photoPath变化,异步加载图片
+watchEffect(async () => {
+  if (form.photoPath) {
+    photoLoading.value = true;
+    try {
+      const blobUrl = await getImageBlobUrl(form.photoPath);
+      asyncPhotoUrl.value = blobUrl;
+    } catch (error) {
+      console.error('加载图片失败:', error);
+      asyncPhotoUrl.value = '';
+    } finally {
+      photoLoading.value = false;
+    }
+  } else {
+    asyncPhotoUrl.value = '';
+    photoLoading.value = false;
+  }
+});
+
 // 监听 visible 属性变化
 watch(
   () => props.visible,
@@ -1666,4 +1736,30 @@ const handleClose = () => {
   border-radius: 6px;
   cursor: default;
 }
+
+/* 照片加载状态样式 */
+.photo-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 150px;
+  height: 150px;
+  border: 2px dashed #d9d9d9;
+  border-radius: 6px;
+  background-color: #fafafa;
+  color: #8c8d91;
+  font-size: 14px;
+  transition: border-color 0.3s;
+}
+
+.photo-loading .el-icon {
+  font-size: 24px;
+  margin-bottom: 8px;
+  color: #409eff;
+}
+
+.photo-loading span {
+  font-size: 12px;
+}
 </style>

+ 134 - 1
web/src/views/xg/xsjcxx/stuMsgAudit/stuMsgDialog.vue

@@ -153,7 +153,11 @@
                   :before-upload="beforePhotoUpload"
                   :http-request="handlePhotoUpload"
                 >
-                  <img v-if="form.photoUrl" :src="form.photoUrl" class="photo" />
+                  <div v-if="photoLoading" class="photo-loading">
+                    <el-icon class="is-loading"><Loading /></el-icon>
+                    <span>加载中...</span>
+                  </div>
+                  <img v-else-if="asyncPhotoUrl" :src="asyncPhotoUrl" class="photo" />
                   <div v-else class="photo-placeholder">暂无照片</div>
                 </el-upload>
               </el-form-item>
@@ -1433,6 +1437,8 @@ import { ref, reactive, getCurrentInstance, nextTick, watch, toRefs } from 'vue'
 import ElMessage from 'element-plus/es/components/message/index';
 import { ElMessageBox } from 'element-plus/es/components/message-box/index';
 import request from '@/utils/request';
+import service from '@/utils/request';
+import { Loading } from '@element-plus/icons-vue';
 // 获取当前组件实例
 const { proxy } = getCurrentInstance() as any;
 
@@ -1510,6 +1516,10 @@ const historyRecords = ref<any[]>([]);
 // 变更详情相关数据
 const changedFields = ref<any[]>([]); // 存储变更的字段信息
 
+// 异步加载的图片URL
+const asyncPhotoUrl = ref<string>('');
+const photoLoading = ref<boolean>(false);
+
 const studentFormRef = ref<any>();
 
 /** 获取历史记录数据 */
@@ -2369,8 +2379,110 @@ const handleApprove = async () => {
   }
 };
 
+// 通过认证请求获取图片数据并转换为blob URL
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  try {
+    // 通过axios请求图片数据,这样会自动带上token和clientId
+    const response = await service({
+      url: '/resource/oss/download/' + imageUrl,
+      method: 'get',
+      responseType: 'blob',
+      timeout: 300000 // 300秒超时
+    });
+
+    // 由于axios响应拦截器对blob类型响应直接返回blob数据
+    // 这里response直接就是blob对象,而不是包含data属性的响应对象
+    const blob = response;
+
+    // 验证响应数据
+    if (!blob) {
+      throw new Error('响应数据为空');
+    }
+
+    if (!(blob instanceof Blob)) {
+      // 如果不是Blob,尝试获取实际内容用于调试
+      let actualContent = 'Unknown';
+      try {
+        if (typeof blob === 'string') {
+          actualContent = (blob as string).substring(0, 200);
+        } else if (blob && typeof blob.toString === 'function') {
+          actualContent = blob.toString().substring(0, 200);
+        }
+      } catch (e) {
+        actualContent = 'Cannot convert to string';
+      }
+
+      throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
+    }
+
+    // 创建blob URL
+    const blobUrl = URL.createObjectURL(blob);
+    return blobUrl;
+  } catch (error) {
+    return '';
+  }
+};
+
+// 防抖定时器
+let photoLoadTimer: number | null = null;
+
+// 监听photoPath变化,异步加载图片
+watch(
+  () => form.photoPath,
+  async (newPhotoPath, oldPhotoPath) => {
+    // 如果路径没有变化,不重复加载
+    if (newPhotoPath === oldPhotoPath) {
+      return;
+    }
+
+    // 清除之前的定时器
+    if (photoLoadTimer) {
+      clearTimeout(photoLoadTimer);
+    }
+
+    // 清理之前的blob URL
+    if (asyncPhotoUrl.value) {
+      URL.revokeObjectURL(asyncPhotoUrl.value);
+      asyncPhotoUrl.value = '';
+    }
+
+    // 防抖处理,避免频繁调用
+    photoLoadTimer = window.setTimeout(async () => {
+      if (newPhotoPath) {
+        photoLoading.value = true;
+        try {
+          const blobUrl = await getImageBlobUrl(newPhotoPath);
+          asyncPhotoUrl.value = blobUrl;
+        } catch (error) {
+          console.error('加载图片失败:', error);
+          asyncPhotoUrl.value = '';
+        } finally {
+          photoLoading.value = false;
+        }
+      } else {
+        asyncPhotoUrl.value = '';
+        photoLoading.value = false;
+      }
+    }, 100); // 100ms 防抖延迟
+  },
+  { immediate: true } // 立即执行一次
+);
+
 // 重置表单数据到默认值
 const resetFormToDefault = () => {
+  // 清理定时器
+  if (photoLoadTimer) {
+    clearTimeout(photoLoadTimer);
+    photoLoadTimer = null;
+  }
+
+  // 清理blob URL,避免内存泄漏
+  if (asyncPhotoUrl.value) {
+    URL.revokeObjectURL(asyncPhotoUrl.value);
+    asyncPhotoUrl.value = '';
+  }
+  photoLoading.value = false;
+
   // 重置为默认值
   Object.assign(form, {
     id: undefined,
@@ -2788,6 +2900,27 @@ const handleClose = () => {
   font-size: 16px;
 }
 
+/* 照片加载样式 */
+.photo-loading {
+  width: 130px;
+  height: 130px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #8c939d;
+  font-size: 14px;
+  background-color: #fafafa;
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  gap: 8px;
+}
+
+.photo-loading .is-loading {
+  font-size: 24px;
+  color: var(--el-color-primary);
+}
+
 /* 变更字段样式 */
 .changed-field-label {
   color: #f56c6c !important;

+ 98 - 2
web/src/views/xg/xsjcxx/stuMsgSupply/stuMsgDetailDialog.vue

@@ -99,7 +99,11 @@
                   </div>
                 </template>
                 <div class="photo-display">
-                  <img v-if="form.photoUrl" :src="form.photoUrl" class="photo" />
+                  <div v-if="photoLoading" class="photo-loading">
+                    <el-icon class="is-loading"><Loading /></el-icon>
+                    <span>加载中...</span>
+                  </div>
+                  <img v-else-if="asyncPhotoUrl" :src="asyncPhotoUrl" class="photo" />
                   <div v-else class="photo-placeholder">暂无照片</div>
                 </div>
               </el-form-item>
@@ -745,10 +749,11 @@
 </template>
 
 <script setup lang="ts" name="StuMsgDetailDialog">
-import { ref, reactive, getCurrentInstance, nextTick, watch, toRefs } from 'vue';
+import { ref, reactive, getCurrentInstance, nextTick, watch, toRefs, watchEffect } from 'vue';
 import ElMessage from 'element-plus/es/components/message/index';
 // @ts-ignore
 import request from '@/utils/request';
+import { Loading } from '@element-plus/icons-vue';
 // 获取当前组件实例
 const { proxy } = getCurrentInstance() as any;
 
@@ -818,6 +823,8 @@ const loading = ref(false);
 const dialogLoading = ref(false); // 对话框整体加载状态
 const regionOptions = ref([]); // 行政区划选项
 const dormBuildingOptions = ref([]); // 宿舍楼栋选项
+const photoLoading = ref(false); // 照片加载状态
+const asyncPhotoUrl = ref(''); // 异步加载的照片URL
 
 // 历史记录相关数据
 const historyLoading = ref(false);
@@ -963,6 +970,50 @@ const getDormBuildingTree = async () => {
   }
 };
 
+// 通过认证请求获取图片数据并转换为blob URL
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  try {
+    // 通过axios请求图片数据,这样会自动带上token和clientId
+    const response = await request({
+      url: '/resource/oss/download/' + imageUrl,
+      method: 'get',
+      responseType: 'blob',
+      timeout: 300000 // 300秒超时
+    });
+
+    // 由于axios响应拦截器对blob类型响应直接返回blob数据
+    // 这里response直接就是blob对象,而不是包含data属性的响应对象
+    const blob = response;
+
+    // 验证响应数据
+    if (!blob) {
+      throw new Error('响应数据为空');
+    }
+
+    if (!(blob instanceof Blob)) {
+      // 如果不是Blob,尝试获取实际内容用于调试
+      let actualContent = 'Unknown';
+      try {
+        if (typeof blob === 'string') {
+          actualContent = (blob as string).substring(0, 200);
+        } else if (blob && typeof blob.toString === 'function') {
+          actualContent = blob.toString().substring(0, 200);
+        }
+      } catch (e) {
+        actualContent = 'Cannot convert to string';
+      }
+
+      throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
+    }
+
+    // 创建blob URL
+    const blobUrl = URL.createObjectURL(blob);
+    return blobUrl;
+  } catch (error) {
+    return '';
+  }
+};
+
 /** 获取用户历史数据 */
 const getUserHistoryData = async (batchId: number) => {
   if (!batchId) {
@@ -1167,6 +1218,25 @@ const rules = reactive({
   gender: [{ required: true, message: '性别不能为空', trigger: 'blur' }]
 });
 
+// 监听form.photoPath变化,异步加载图片
+watchEffect(async () => {
+  if (form.photoPath) {
+    photoLoading.value = true;
+    try {
+      const blobUrl = await getImageBlobUrl(form.photoPath);
+      asyncPhotoUrl.value = blobUrl;
+    } catch (error) {
+      console.error('加载图片失败:', error);
+      asyncPhotoUrl.value = '';
+    } finally {
+      photoLoading.value = false;
+    }
+  } else {
+    asyncPhotoUrl.value = '';
+    photoLoading.value = false;
+  }
+});
+
 // 监听 visible 属性变化
 watch(
   () => props.visible,
@@ -1662,4 +1732,30 @@ const handleClose = () => {
   border-radius: 6px;
   cursor: default;
 }
+
+/* 照片加载状态样式 */
+.photo-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 150px;
+  height: 150px;
+  border: 2px dashed #d9d9d9;
+  border-radius: 6px;
+  background-color: #fafafa;
+  color: #8c8d91;
+  font-size: 14px;
+  transition: border-color 0.3s;
+}
+
+.photo-loading .el-icon {
+  font-size: 24px;
+  margin-bottom: 8px;
+  color: #409eff;
+}
+
+.photo-loading span {
+  font-size: 12px;
+}
 </style>

+ 148 - 2
web/src/views/xg/xsjcxx/stuMsgSupply/stuMsgDialog.vue

@@ -105,7 +105,11 @@
                   :before-upload="beforePhotoUpload"
                   :http-request="handlePhotoUpload"
                 >
-                  <img v-if="form.photoUrl" :src="form.photoUrl" class="photo" />
+                  <div v-if="photoLoading" class="photo-loading">
+                    <el-icon class="is-loading"><Loading /></el-icon>
+                    <span>加载中...</span>
+                  </div>
+                  <img v-else-if="asyncPhotoUrl" :src="asyncPhotoUrl" class="photo" />
                   <div v-else class="photo-placeholder">暂无照片</div>
                 </el-upload>
               </el-form-item>
@@ -751,9 +755,11 @@
 </template>
 
 <script setup lang="ts" name="StuMsgDialog">
-import { ref, reactive, getCurrentInstance, nextTick, watch, toRefs } from 'vue';
+import { ref, reactive, getCurrentInstance, nextTick, watch, toRefs, watchEffect } from 'vue';
 import ElMessage from 'element-plus/es/components/message/index';
 import request from '@/utils/request';
+import service from '@/utils/request';
+import { Loading } from '@element-plus/icons-vue';
 // 获取当前组件实例
 const { proxy } = getCurrentInstance() as any;
 
@@ -828,6 +834,10 @@ const dormBuildingOptions = ref([]); // 宿舍楼栋选项
 const historyLoading = ref(false);
 const historyRecords = ref<any[]>([]);
 
+// 异步加载的图片URL
+const asyncPhotoUrl = ref<string>('');
+const photoLoading = ref<boolean>(false);
+
 const studentFormRef = ref<any>();
 
 /** 获取历史记录数据 */
@@ -1339,6 +1349,19 @@ const submitForm = () => {
 
 // 重置表单数据到默认值
 const resetFormToDefault = () => {
+  // 清理定时器
+  if (photoLoadTimer) {
+    clearTimeout(photoLoadTimer);
+    photoLoadTimer = null;
+  }
+
+  // 清理blob URL,避免内存泄漏
+  if (asyncPhotoUrl.value) {
+    URL.revokeObjectURL(asyncPhotoUrl.value);
+    asyncPhotoUrl.value = '';
+  }
+  photoLoading.value = false;
+
   // 重置为默认值
   Object.assign(form, {
     id: undefined,
@@ -1455,12 +1478,114 @@ const handleClose = () => {
   // 重置历史记录数据
   historyRecords.value = [];
 
+  // 清理定时器
+  if (photoLoadTimer) {
+    clearTimeout(photoLoadTimer);
+    photoLoadTimer = null;
+  }
+
+  // 清理blob URL,避免内存泄漏
+  if (asyncPhotoUrl.value) {
+    URL.revokeObjectURL(asyncPhotoUrl.value);
+    asyncPhotoUrl.value = '';
+  }
+  photoLoading.value = false;
+
   // 重置表单
   nextTick(() => {
     studentFormRef.value?.resetFields();
     resetFormToDefault(); // 重置表单数据到默认值
   });
 };
+
+// 通过认证请求获取图片数据并转换为blob URL
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  try {
+    // 通过axios请求图片数据,这样会自动带上token和clientId
+    const response = await service({
+      url: '/resource/oss/download/' + imageUrl,
+      method: 'get',
+      responseType: 'blob',
+      timeout: 300000 // 300秒超时
+    });
+
+    // 由于axios响应拦截器对blob类型响应直接返回blob数据
+    // 这里response直接就是blob对象,而不是包含data属性的响应对象
+    const blob = response;
+
+    // 验证响应数据
+    if (!blob) {
+      throw new Error('响应数据为空');
+    }
+
+    if (!(blob instanceof Blob)) {
+      // 如果不是Blob,尝试获取实际内容用于调试
+      let actualContent = 'Unknown';
+      try {
+        if (typeof blob === 'string') {
+          actualContent = (blob as string).substring(0, 200);
+        } else if (blob && typeof blob.toString === 'function') {
+          actualContent = blob.toString().substring(0, 200);
+        }
+      } catch (e) {
+        actualContent = 'Cannot convert to string';
+      }
+
+      throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
+    }
+
+    // 创建blob URL
+    const blobUrl = URL.createObjectURL(blob);
+    return blobUrl;
+  } catch (error) {
+    return '';
+  }
+};
+
+// 防抖定时器
+let photoLoadTimer: number | null = null;
+
+// 监听photoPath变化,异步加载图片
+watch(
+  () => form.photoPath,
+  async (newPhotoPath, oldPhotoPath) => {
+    // 如果路径没有变化,不重复加载
+    if (newPhotoPath === oldPhotoPath) {
+      return;
+    }
+
+    // 清除之前的定时器
+    if (photoLoadTimer) {
+      clearTimeout(photoLoadTimer);
+    }
+
+    // 清理之前的blob URL
+    if (asyncPhotoUrl.value) {
+      URL.revokeObjectURL(asyncPhotoUrl.value);
+      asyncPhotoUrl.value = '';
+    }
+
+    // 防抖处理,避免频繁调用
+    photoLoadTimer = setTimeout(async () => {
+      if (newPhotoPath) {
+        photoLoading.value = true;
+        try {
+          const blobUrl = await getImageBlobUrl(newPhotoPath);
+          asyncPhotoUrl.value = blobUrl;
+        } catch (error) {
+          console.error('加载图片失败:', error);
+          asyncPhotoUrl.value = '';
+        } finally {
+          photoLoading.value = false;
+        }
+      } else {
+        asyncPhotoUrl.value = '';
+        photoLoading.value = false;
+      }
+    }, 100); // 100ms 防抖延迟
+  },
+  { immediate: true } // 立即执行一次
+);
 </script>
 
 <style scoped>
@@ -1742,4 +1867,25 @@ const handleClose = () => {
   cursor: help;
   font-size: 16px;
 }
+
+/* 照片加载样式 */
+.photo-loading {
+  width: 130px;
+  height: 130px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #8c939d;
+  font-size: 14px;
+  background-color: #fafafa;
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  gap: 8px;
+}
+
+.photo-loading .is-loading {
+  font-size: 24px;
+  color: var(--el-color-primary);
+}
 </style>

+ 98 - 2
web/src/views/xg/xsjcxx/stuPersonalMsg/index.vue

@@ -70,7 +70,11 @@
                     </div>
                   </template>
                   <div class="photo-display">
-                    <img v-if="form.photoUrl" :src="form.photoUrl" class="photo" />
+                    <div v-if="photoLoading" class="photo-loading">
+                      <el-icon class="is-loading"><Loading /></el-icon>
+                      <span>加载中...</span>
+                    </div>
+                    <img v-else-if="asyncPhotoUrl" :src="asyncPhotoUrl" class="photo" />
                     <div v-else class="photo-placeholder">暂无照片</div>
                   </div>
                 </el-form-item>
@@ -725,9 +729,10 @@
 </template>
 
 <script setup lang="ts" name="StuPersonalMsg">
-import { ref, reactive, getCurrentInstance, onMounted, toRefs } from 'vue';
+import { ref, reactive, getCurrentInstance, onMounted, toRefs, watchEffect } from 'vue';
 import request from '@/utils/request';
 import ElMessage from 'element-plus/es/components/message/index';
+import { Loading } from '@element-plus/icons-vue';
 
 // 获取当前组件实例
 const { proxy } = getCurrentInstance() as any;
@@ -779,6 +784,8 @@ const activeTab = ref('basic'); // 默认显示基础信息
 const regionOptions = ref([]); // 行政区划选项
 const dormBuildingOptions = ref([]); // 宿舍楼栋选项
 const loading = ref(false); // 加载状态
+const photoLoading = ref(false); // 照片加载状态
+const asyncPhotoUrl = ref(''); // 异步加载的照片URL
 
 // 表单数据
 const form = reactive({
@@ -999,6 +1006,69 @@ const getDormBuildingTree = async () => {
   }
 };
 
+// 通过认证请求获取图片数据并转换为blob URL
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  try {
+    // 通过axios请求图片数据,这样会自动带上token和clientId
+    const response = await request({
+      url: '/resource/oss/download/' + imageUrl,
+      method: 'get',
+      responseType: 'blob',
+      timeout: 300000 // 300秒超时
+    });
+
+    // 由于axios响应拦截器对blob类型响应直接返回blob数据
+    // 这里response直接就是blob对象,而不是包含data属性的响应对象
+    const blob = response;
+
+    // 验证响应数据
+    if (!blob) {
+      throw new Error('响应数据为空');
+    }
+
+    if (!(blob instanceof Blob)) {
+      // 如果不是Blob,尝试获取实际内容用于调试
+      let actualContent = 'Unknown';
+      try {
+        if (typeof blob === 'string') {
+          actualContent = (blob as string).substring(0, 200);
+        } else if (blob && typeof blob.toString === 'function') {
+          actualContent = blob.toString().substring(0, 200);
+        }
+      } catch (e) {
+        actualContent = 'Cannot convert to string';
+      }
+
+      throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
+    }
+
+    // 创建blob URL
+    const blobUrl = URL.createObjectURL(blob);
+    return blobUrl;
+  } catch (error) {
+    return '';
+  }
+};
+
+// 监听form.photoPath变化,异步加载图片
+watchEffect(async () => {
+  if (form.photoPath) {
+    photoLoading.value = true;
+    try {
+      const blobUrl = await getImageBlobUrl(form.photoPath);
+      asyncPhotoUrl.value = blobUrl;
+    } catch (error) {
+      console.error('加载图片失败:', error);
+      asyncPhotoUrl.value = '';
+    } finally {
+      photoLoading.value = false;
+    }
+  } else {
+    asyncPhotoUrl.value = '';
+    photoLoading.value = false;
+  }
+});
+
 // 组件挂载时初始化数据
 onMounted(async () => {
   await Promise.all([getRegionTree(), getDormBuildingTree()]);
@@ -1090,6 +1160,32 @@ onMounted(async () => {
   cursor: default;
 }
 
+/* 照片加载状态样式 */
+.photo-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 150px;
+  height: 150px;
+  border: 2px dashed #d9d9d9;
+  border-radius: 6px;
+  background-color: #fafafa;
+  color: #8c8d91;
+  font-size: 14px;
+  transition: border-color 0.3s;
+}
+
+.photo-loading .el-icon {
+  font-size: 24px;
+  margin-bottom: 8px;
+  color: #409eff;
+}
+
+.photo-loading span {
+  font-size: 12px;
+}
+
 /* Tab页样式优化 */
 :deep(.el-tabs__content) {
   padding: 0;

+ 98 - 2
web/src/views/xg/xsjcxx/stuRoster/components/StudentDetailDialog.vue

@@ -66,7 +66,11 @@
                     </div>
                   </template>
                   <div class="photo-display">
-                    <img v-if="form.photoUrl" :src="form.photoUrl" class="photo" />
+                    <div v-if="photoLoading" class="photo-loading">
+                      <el-icon class="is-loading"><Loading /></el-icon>
+                      <span>加载中...</span>
+                    </div>
+                    <img v-else-if="asyncPhotoUrl" :src="asyncPhotoUrl" class="photo" />
                     <div v-else class="photo-placeholder">暂无照片</div>
                   </div>
                 </el-form-item>
@@ -721,10 +725,11 @@
 </template>
 
 <script setup lang="ts" name="StuPersonalMsg">
-import { ref, reactive, getCurrentInstance, onMounted, toRefs, watch, computed } from 'vue';
+import { ref, reactive, getCurrentInstance, onMounted, toRefs, watch, computed, watchEffect } from 'vue';
 import request from '@/utils/request';
 import ElMessage from 'element-plus/es/components/message/index';
 import api from '@/api/xg/xsjcxx/stuRoster/index';
+import { Loading } from '@element-plus/icons-vue';
 
 // 定义props
 interface Props {
@@ -798,6 +803,8 @@ const activeTab = ref('basic'); // 默认显示基础信息
 const regionOptions = ref([]); // 行政区划选项
 const dormBuildingOptions = ref([]); // 宿舍楼栋选项
 const loading = ref(false); // 加载状态
+const photoLoading = ref<boolean>(false); // 照片加载状态
+const asyncPhotoUrl = ref<string>(''); // 异步加载的照片URL
 
 // 表单数据
 const form = reactive({
@@ -1021,6 +1028,50 @@ const getDormBuildingTree = async () => {
   }
 };
 
+// 通过认证请求获取图片数据并转换为blob URL
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  try {
+    // 通过axios请求图片数据,这样会自动带上token和clientId
+    const response = await request({
+      url: '/resource/oss/download/' + imageUrl,
+      method: 'get',
+      responseType: 'blob',
+      timeout: 300000 // 300秒超时
+    });
+
+    // 由于axios响应拦截器对blob类型响应直接返回blob数据
+    // 这里response直接就是blob对象,而不是包含data属性的响应对象
+    const blob = response;
+
+    // 验证响应数据
+    if (!blob) {
+      throw new Error('响应数据为空');
+    }
+
+    if (!(blob instanceof Blob)) {
+      // 如果不是Blob,尝试获取实际内容用于调试
+      let actualContent = 'Unknown';
+      try {
+        if (typeof blob === 'string') {
+          actualContent = (blob as string).substring(0, 200);
+        } else if (blob && typeof blob.toString === 'function') {
+          actualContent = blob.toString().substring(0, 200);
+        }
+      } catch (e) {
+        actualContent = 'Cannot convert to string';
+      }
+
+      throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
+    }
+
+    // 创建blob URL
+    const blobUrl = URL.createObjectURL(blob);
+    return blobUrl;
+  } catch (error) {
+    return '';
+  }
+};
+
 // 监听学生ID变化,当弹窗打开且有学生ID时获取数据
 watch(
   () => [props.visible, props.studentId],
@@ -1048,6 +1099,25 @@ watch(
   { immediate: true }
 );
 
+// 监听form.photoPath变化,异步加载图片
+watchEffect(async () => {
+  if (form.photoPath) {
+    photoLoading.value = true;
+    try {
+      const blobUrl = await getImageBlobUrl(form.photoPath);
+      asyncPhotoUrl.value = blobUrl;
+    } catch (error) {
+      console.error('加载图片失败:', error);
+      asyncPhotoUrl.value = '';
+    } finally {
+      photoLoading.value = false;
+    }
+  } else {
+    asyncPhotoUrl.value = '';
+    photoLoading.value = false;
+  }
+});
+
 // 组件挂载时初始化基础数据
 onMounted(async () => {
   await Promise.all([getRegionTree(), getDormBuildingTree()]);
@@ -1161,6 +1231,32 @@ defineOptions({
   cursor: default;
 }
 
+/* 照片加载状态样式 */
+.photo-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 150px;
+  height: 150px;
+  border: 2px dashed #d9d9d9;
+  border-radius: 6px;
+  background-color: #fafafa;
+  color: #8c8d91;
+  font-size: 14px;
+  transition: border-color 0.3s;
+}
+
+.photo-loading .el-icon {
+  font-size: 24px;
+  margin-bottom: 8px;
+  color: #409eff;
+}
+
+.photo-loading span {
+  font-size: 12px;
+}
+
 /* Tab页样式优化 */
 :deep(.el-tabs__header .el-tabs__item.is-active) {
   color: var(--el-color-primary) !important;

+ 200 - 7
web/src/views/xg/xsjcxx/stuRoster/index.vue

@@ -167,15 +167,23 @@
                         <div class="student-photo">
                           <el-avatar
                             :size="100"
-                            :src="item.photoUrl"
-                            :icon="!item.photoUrl ? (item.gender === '0' ? 'Male' : 'Female') : undefined"
+                            :src="getStudentPhotoUrl(item)"
+                            :icon="!getStudentPhotoUrl(item) ? (item.gender === '0' ? 'Male' : 'Female') : undefined"
                             :class="{
-                              'male-placeholder': !item.photoUrl && item.gender === '0',
-                              'female-placeholder': !item.photoUrl && item.gender === '1'
+                              'male-placeholder': !getStudentPhotoUrl(item) && item.gender === '0',
+                              'female-placeholder': !getStudentPhotoUrl(item) && item.gender === '1'
                             }"
                           >
-                            <template v-if="!item.photoUrl">
+                            <template v-if="!getStudentPhotoUrl(item)">
+                              <!-- 加载中状态 -->
+                              <div v-if="isPhotoLoading(item)" class="photo-loading-container">
+                                <el-icon class="is-loading" size="24" color="#409eff">
+                                  <Loading />
+                                </el-icon>
+                              </div>
+                              <!-- 无照片状态 -->
                               <div
+                                v-else
                                 class="no-photo-placeholder"
                                 :class="{ 'male-placeholder': item.gender === '0', 'female-placeholder': item.gender === '1' }"
                               >
@@ -187,6 +195,15 @@
                               </div>
                             </template>
                           </el-avatar>
+                          <!-- 照片加载中状态文字 - 显示在头像底部 -->
+                          <div v-if="isPhotoLoading(item)" class="photo-loading-text">
+                            <el-text type="primary" size="small">
+                              <el-icon class="is-loading mr-1" size="12">
+                                <Loading />
+                              </el-icon>
+                              图像加载中...
+                            </el-text>
+                          </div>
                         </div>
                         <div class="student-info">
                           <h3 class="student-name">{{ item.name }}</h3>
@@ -281,10 +298,11 @@
 import { ref, reactive, toRefs, watchEffect, onMounted, computed, getCurrentInstance } from 'vue';
 import ElMessage from 'element-plus/es/components/message/index';
 import ElMessageBox from 'element-plus/es/components/message-box/index';
-import { Search, Refresh, Setting, Download, List, Grid, View, Picture, Male, Female } from '@element-plus/icons-vue';
+import { Search, Refresh, Setting, Download, List, Grid, View, Picture, Male, Female, Loading } from '@element-plus/icons-vue';
 import AdvancedSearchDialog from './components/AdvancedSearchDialog.vue';
 import StudentDetailDialog from './components/StudentDetailDialog.vue';
 import api from '@/api/xg/xsjcxx/stuRoster/index';
+import request from '@/utils/request';
 
 const { proxy } = getCurrentInstance() as any;
 
@@ -297,6 +315,10 @@ const deptName = ref('');
 const deptTreeRef = ref();
 const selectedDepts = ref<number[]>([]);
 
+// 学生照片缓存
+const studentPhotoCache = ref<Map<string, string>>(new Map());
+const photoLoadingCache = ref<Map<string, boolean>>(new Map());
+
 // 计算表格最大高度
 const tableMaxHeight = computed(() => {
   // 基础高度:视窗高度减去头部、搜索区域、工具栏、分页等区域的高度
@@ -834,6 +856,99 @@ const getStudentStatusLabel = (status: string) => {
   return statusMap[status] || '未知';
 };
 
+// 通过认证请求获取图片数据并转换为blob URL
+const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
+  try {
+    // 通过axios请求图片数据,这样会自动带上token和clientId
+    const response = await request({
+      url: '/resource/oss/download/' + imageUrl,
+      method: 'get',
+      responseType: 'blob',
+      timeout: 300000 // 300秒超时
+    });
+
+    // 由于axios响应拦截器对blob类型响应直接返回blob数据
+    // 这里response直接就是blob对象,而不是包含data属性的响应对象
+    const blob = response;
+
+    // 验证响应数据
+    if (!blob) {
+      throw new Error('响应数据为空');
+    }
+
+    if (!(blob instanceof Blob)) {
+      // 如果不是Blob,尝试获取实际内容用于调试
+      let actualContent = 'Unknown';
+      try {
+        if (typeof blob === 'string') {
+          actualContent = (blob as string).substring(0, 200);
+        } else if (blob && typeof blob.toString === 'function') {
+          actualContent = blob.toString().substring(0, 200);
+        }
+      } catch (e) {
+        actualContent = 'Cannot convert to string';
+      }
+
+      throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
+    }
+
+    // 创建blob URL
+    const blobUrl = URL.createObjectURL(blob);
+    return blobUrl;
+  } catch (error) {
+    return '';
+  }
+};
+
+// 获取学生照片URL(带缓存)
+const getStudentPhotoUrl = (student: any): string => {
+  const photoPath = student.photoPath;
+  if (!photoPath) {
+    return '';
+  }
+
+  // 检查缓存
+  if (studentPhotoCache.value.has(photoPath)) {
+    return studentPhotoCache.value.get(photoPath) || '';
+  }
+
+  // 如果正在加载,返回空字符串
+  if (photoLoadingCache.value.get(photoPath)) {
+    return '';
+  }
+
+  // 开始异步加载
+  loadStudentPhoto(photoPath);
+  return '';
+};
+
+// 异步加载学生照片
+const loadStudentPhoto = async (photoPath: string) => {
+  // 标记为正在加载
+  photoLoadingCache.value.set(photoPath, true);
+
+  try {
+    const blobUrl = await getImageBlobUrl(photoPath);
+    if (blobUrl) {
+      studentPhotoCache.value.set(photoPath, blobUrl);
+    }
+  } catch (error) {
+    console.error('加载学生照片失败:', error);
+  } finally {
+    // 标记加载完成
+    photoLoadingCache.value.set(photoPath, false);
+  }
+};
+
+// 检查照片是否正在加载
+const isPhotoLoading = (student: any): boolean => {
+  const photoPath = student.photoPath;
+  if (!photoPath) {
+    return false;
+  }
+  return photoLoadingCache.value.get(photoPath) || false;
+};
+
 // 监听部门名称变化
 watchEffect(() => {
   if (deptTreeRef.value) {
@@ -1146,7 +1261,7 @@ onMounted(() => {
 
 .student-card .card-header {
   display: flex;
-  align-items: center;
+  align-items: flex-start;
   margin-bottom: 12px;
   gap: 16px;
   flex-shrink: 0;
@@ -1154,6 +1269,39 @@ onMounted(() => {
 
 .student-photo {
   flex-shrink: 0;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+/* 照片加载中状态文字样式 */
+.photo-loading-text {
+  margin-top: 4px;
+  text-align: center;
+  background-color: #f0f9ff;
+  transition: all 0.3s ease;
+  animation: pulse 2s infinite;
+}
+
+.photo-loading-text .el-text {
+  font-weight: 500;
+}
+
+.photo-loading-text .el-icon {
+  animation: rotate 1s linear infinite;
+}
+
+@keyframes pulse {
+  0%,
+  100% {
+    opacity: 1;
+    transform: scale(1);
+  }
+  50% {
+    opacity: 0.8;
+    transform: scale(1.05);
+  }
 }
 
 .student-info {
@@ -1225,6 +1373,40 @@ onMounted(() => {
   transition: all 0.3s ease;
 }
 
+/* 照片加载状态样式 */
+.photo-loading-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  width: 100%;
+  background-color: #f5f7fa !important;
+  border-radius: 50%;
+  color: #8c8d91;
+  transition: all 0.3s ease;
+}
+
+.photo-loading-container .el-icon {
+  margin-bottom: 4px;
+  animation: rotate 1s linear infinite;
+}
+
+.photo-loading-container .loading-text {
+  font-size: 10px;
+  color: #409eff;
+  font-weight: 500;
+}
+
+@keyframes rotate {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
 /* 男生占位符样式 */
 .male-placeholder {
   background: #e6f3ff !important;
@@ -1370,6 +1552,17 @@ onMounted(() => {
     margin-top: 4px !important;
   }
 
+  /* 移动端照片加载中文字样式调整 */
+  .photo-loading-text {
+    margin-top: 6px;
+    padding: 3px 6px;
+    font-size: 11px;
+  }
+
+  .photo-loading-text .el-icon {
+    font-size: 10px !important;
+  }
+
   .student-name {
     font-size: 16px;
   }