This commit is contained in:
zlwu committed 2025-11-28 21:46:23 +08:00
1 parent 375a2c8df7
commit ba7b7b8497
9 files changed
+343 -57

No files matched your search

@@ -75,11 +75,16 @@ public class SysUserVo implements Serializable {
private String sex;
/**
* 头像地址
* 头像 OSS ID
*/
@Translation(type = TransConstant.OSS_ID_TO_URL)
private Long avatar;
/**
* 头像访问地址
*/
@Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "avatar")
private String avatarUrl;
/**
* 密码
*/
+16 -4
View File
@@ -395,7 +395,8 @@ loginWithEncryption()
"email": "admin@example.com",
"phonenumber": "13800138000",
"sex": "1",
"avatar": "/profile/avatar/default.jpg",
"avatar": "1938120323613728770",
"avatarUrl": "http://example.com/ruoyi/2025/08/31/xxxx.jpeg?X-Amz-Algorithm=AWS4-HMAC-SHA256&...",
"status": "0",
"loginIp": "127.0.0.1",
"loginDate": "2025-01-26 10:30:00",
@@ -409,6 +410,13 @@ loginWithEncryption()
}
```
**字段说明补充**:
- `avatar`:始终返回 `sys_user.avatar` 中存储的 OSS ID(字符串),用于鉴权下载接口 `/resource/oss/download/{ossId}`。
- `avatarUrl`:系统通过 `TransConstant.OSS_ID_TO_URL` 规则实时翻译得到的可直接访问地址(MinIO 预签名 URL),供无需链路改造的场景快速显示;有效期受 OSS 策略控制。
前端如需在带 Token 的受保护组件(如 `ImagePreview`)中展示,请优先使用 `avatar` 并通过统一的认证下载逻辑获取图片,避免直接暴露临时 URL。
**超级管理员权限说明**:
admin 用户具有系统最高权限`*:*:*`,可以访问所有功能模块和执行所有操作。
@@ -663,7 +671,9 @@ methods: {
---
**📝 维护**: 用户认证登录 API 文档
**🔄 更新**: 2025-01-03
**🔄 更新记录**:
- 2025-11-28:`/system/user/getInfo` 增加 `avatarUrl` 字段,并明确 `avatar` 返回 OSS ID
- 2025-01-03:文档初始化
**📁 文件**: 用户认证登录 API.md
## 获取具有指定权限的用户列表
@@ -693,7 +703,8 @@ methods: {
deptId: string; // 部门ID
deptName: string; // 部门名称
};
avatar: string; // 头像URL
avatar: string; // 头像OSS ID
avatarUrl?: string; // 头像访问URL(可选,预签名)
}[];
total: number; // 总记录数
}
@@ -713,7 +724,8 @@ methods: {
"deptId": "1",
"deptName": "后勤部"
},
"avatar": "http://example.com/avatar.jpg"
"avatar": "1938120323613728770",
"avatarUrl": "http://example.com/avatar.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&..."
}
],
"total": 1
@@ -65,6 +65,36 @@ ImagePreview 是一个图片预览组件,支持两种模式:
</template>
```
### 4. 顶部个人头像(自动识别OSS ID)
```vue
<script setup lang="ts">
import { computed } from 'vue';
import ImagePreview from '@/components/ImagePreview/index.vue';
import { useUserStore } from '@/store/modules/user';
const userStore = useUserStore();
const avatarIsOssId = computed(() => {
const avatar = userStore.avatar;
if (!avatar) return false;
return !/^https?:\/\//i.test(avatar) && !avatar.startsWith('/');
});
</script>
<template>
<ImagePreview
v-if="userStore.avatar"
class="user-avatar"
:src="userStore.avatar"
:width="24"
:height="24"
:is-oss-id="avatarIsOssId"
/>
</template>
```
> ✅ 用于首页 `Navbar` 头像区域时,`avatarIsOssId` 会自动识别当前值是否为 OSS ID,从而在带 Token 的 `/resource/oss/download/{ossId}` 接口与传统 URL 之间无缝切换,保证头像始终通过认证通道加载。
## 技术实现
### 核心功能
@@ -126,6 +156,7 @@ const urlCache = ref<Map<string, string>>(new Map());
## 更新记录
- **2025-11-28**:补充顶部栏个人头像场景示例,强调 OSS ID 自动识别策略,确保 `Navbar` 等公共区域也复用统一的图片安全加载方案。
- **2025-08-02**:修复组件,支持OSS ID模式,添加缓存机制和加载状态
- 修复了导入错误和computed未定义的问题
- 添加了完整的错误处理和日志记录
@@ -737,6 +737,13 @@ export default {
</script>
```
#### 头像鉴权加载
- 统一从 `/system/user/getInfo` 获取 `avatar`(OSS ID)与 `avatarUrl`(预签名地址)。
- 页面中优先识别 `avatar` 是否为 OSS ID,使用 `uni.request`(`responseType: 'arraybuffer'`)访问 `/resource/oss/download/{ossId}` 并转换为 `Blob URL/Base64`,确保 Token 认证生效。
- 若历史账号仍存储直接 URL,则回退到 `avatar` 或 `avatarUrl` 原始值,最后兜底 `/static/111.png`。
- 首页、详情页等通用区域必需复用该策略,避免直接暴露预签名链接导致过期或越权。
#### 列表优化
```vue
+219 -5
View File
@@ -681,7 +681,8 @@ import Card from '@/components/Card.vue';
import ServiceItem from '@/components/ServiceItem.vue';
import GridLayout from '@/components/GridLayout.vue';
import { userManager, userState } from '@/utils/user';
import { api } from '@/common/request';
import { api, request } from '@/common/request';
import { REQUEST_CONFIG } from '@/common/config';
import { hasPermi, permissionMixin } from '@/utils/permission';
import RouterManager from '@/utils/router';
@@ -698,8 +699,10 @@ export default {
userInfo: {
name: '用户',
avatar: '',
avatarOssId: '',
deptName: '',
},
avatarBlobUrl: '',
// 轮播图片数据
bannerList: [
{
@@ -817,6 +820,12 @@ export default {
this.checkLoginStatus();
this.loadUserInfo();
},
onUnload() {
this.cleanupAvatarBlob();
},
beforeDestroy() {
this.cleanupAvatarBlob();
},
methods: {
// 全局权限校验方法
hasPermi(permissions) {
@@ -831,15 +840,220 @@ export default {
},
// 加载用户信息
loadUserInfo() {
const displayUser = userManager.formatUserForDisplay();
if (displayUser) {
async loadUserInfo() {
try {
const displayUser = userManager.formatUserForDisplay();
if (!displayUser) {
return;
}
this.userInfo.name = displayUser.name;
this.userInfo.avatar = displayUser.avatar;
this.userInfo.deptName = displayUser.deptName;
this.userInfo.avatar = '';
this.userInfo.avatarOssId = '';
if (!displayUser.avatar) {
return;
}
const resolvedAvatar = await this.resolveAvatarValue(displayUser.avatar);
if (resolvedAvatar) {
const displayableUrl = await this.getDisplayableAvatarUrl(
resolvedAvatar.url,
resolvedAvatar.ossId,
);
this.userInfo.avatar = displayableUrl || resolvedAvatar.url;
this.userInfo.avatarOssId = resolvedAvatar.ossId || '';
}
} catch (error) {
console.error('加载用户信息失败:', error);
}
},
// 统一处理头像字段,兼容URL与OSS ID
async resolveAvatarValue(rawAvatar) {
const avatarValue = (rawAvatar || '').toString().trim();
if (!avatarValue) {
return null;
}
// 直接可用的URL
if (this.isDirectUrl(avatarValue)) {
return { url: avatarValue, ossId: '' };
}
// 后端仅返回了相对路径(例如/resource/oss/preview/xxx 或 /api/resource/...)
if (avatarValue.startsWith('/resource/') || avatarValue.startsWith('/api/resource/')) {
return {
url: this.normalizeResourceUrl(avatarValue),
ossId: '',
};
}
// 其余场景按OSS ID处理
const ossId = avatarValue.split(',')[0].trim();
if (!ossId) {
return null;
}
const urlFromOss = await this.fetchAvatarFromOss(ossId);
return urlFromOss
? {
url: urlFromOss,
ossId,
}
: null;
},
// 判断是否为绝对可用的URL
isDirectUrl(value) {
const lower = value.toLowerCase();
return (
this.isAbsoluteExternalUrl(lower) ||
lower.startsWith('data:') ||
lower.startsWith('blob:') ||
value.startsWith('/static/')
);
},
isAbsoluteExternalUrl(value) {
const lower = value.toLowerCase();
return lower.startsWith('http://') || lower.startsWith('https://');
},
// 将后端返回的/resource路径转换成可直接展示的URL
normalizeResourceUrl(url) {
if (!url) return '';
if (url.startsWith('/api/')) {
return url;
}
return `${REQUEST_CONFIG.serverUrl}${url}`;
},
// 确保头像URL可以直接展示(必要时转换为blob URL)
async getDisplayableAvatarUrl(url, ossId) {
if (!url) {
return '';
}
// 静态资源或可匿名访问的URL直接返回
if (this.isAbsoluteExternalUrl(url) || url.startsWith('/static/')) {
return url;
}
// 优先尝试把现有URL转换为blob,确保请求可以带上Token
const blobUrl = await this.fetchImageBlobUrl(url);
if (blobUrl) {
this.updateAvatarBlobUrl(blobUrl);
return blobUrl;
}
// 兜底:通过下载接口重新获取
if (ossId) {
const downloadPath = `/resource/oss/download/${encodeURIComponent(ossId)}`;
const downloadBlobUrl = await this.fetchImageBlobUrl(downloadPath);
if (downloadBlobUrl) {
this.updateAvatarBlobUrl(downloadBlobUrl);
return downloadBlobUrl;
}
}
return url;
},
updateAvatarBlobUrl(blobUrl) {
if (!blobUrl) {
return;
}
this.cleanupAvatarBlob();
this.avatarBlobUrl = blobUrl;
},
cleanupAvatarBlob() {
if (this.avatarBlobUrl && this.avatarBlobUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(this.avatarBlobUrl);
} catch (error) {
console.warn('释放头像blob URL失败:', error);
}
}
this.avatarBlobUrl = '';
},
async fetchImageBlobUrl(imageUrl) {
try {
if (!imageUrl) {
return '';
}
if (this.isAbsoluteExternalUrl(imageUrl) || imageUrl.startsWith('/static/')) {
return imageUrl;
}
let requestUrl = imageUrl;
const serverPrefix = REQUEST_CONFIG.serverUrl || '';
if (
serverPrefix &&
requestUrl.startsWith(serverPrefix) &&
serverPrefix.startsWith('/')
) {
requestUrl = requestUrl.slice(serverPrefix.length);
if (!requestUrl.startsWith('/')) {
requestUrl = `/${requestUrl}`;
}
}
if (requestUrl.startsWith('/api/')) {
requestUrl = requestUrl.replace('/api', '');
}
if (!requestUrl.startsWith('/')) {
requestUrl = `/${requestUrl}`;
}
const arrayBuffer = await request({
url: requestUrl,
method: 'GET',
responseType: 'arraybuffer',
});
if (!arrayBuffer) {
return '';
}
const blob = new Blob([arrayBuffer], { type: 'image/*' });
return URL.createObjectURL(blob);
} catch (error) {
console.error('获取头像blob失败:', error);
return '';
}
},
// 通过OSS ID获取文件访问URL
async fetchAvatarFromOss(ossId) {
try {
const response = await request({
url: `/resource/oss/listByIds/${encodeURIComponent(ossId)}`,
method: 'GET',
});
if (
response.code === 200 &&
Array.isArray(response.data) &&
response.data.length > 0
) {
const fileInfo = response.data[0];
if (fileInfo?.url) {
return this.normalizeResourceUrl(fileInfo.url);
}
}
} catch (error) {
console.error('根据OSS ID获取头像失败:', error);
}
return '';
},
// 点击用户头像
handleUserClick() {
// const info = userManager.getUserInfoString();
+1
View File
@@ -37,6 +37,7 @@ export interface UserVO extends BaseEntity {
phonenumber: string;
sex: string;
avatar: string;
avatarUrl?: string;
status: string;
delFlag: string;
loginIp: string;
+14 -1
View File
@@ -75,7 +75,8 @@
<div class="avatar-container">
<el-dropdown class="right-menu-item hover-effect" trigger="click" @command="handleCommand">
<div class="avatar-wrapper">
<img :src="userStore.avatar" class="user-avatar" />
<ImagePreview v-if="userStore.avatar" class="user-avatar" :src="userStore.avatar" :width="24" :height="24" :is-oss-id="avatarIsOssId" />
<img v-else :src="userStore.avatar" class="user-avatar" />
<el-icon><caret-bottom /></el-icon>
</div>
<template #dropdown>
@@ -98,9 +99,12 @@
</template>
<script setup lang="ts">
import { ComponentInternalInstance, computed, getCurrentInstance, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import Hamburger from '@/components/Hamburger/index.vue';
import TopNav from '@/components/TopNav/index.vue';
import SearchMenu from './TopBar/search.vue';
import ImagePreview from '@/components/ImagePreview/index.vue';
import { useAppStore } from '@/store/modules/app';
import { useUserStore } from '@/store/modules/user';
import { useSettingsStore } from '@/store/modules/settings';
@@ -129,6 +133,15 @@ const dynamic = ref(false);
const tenantEnabled = ref(true);
// 搜索菜单
const searchMenuRef = ref<InstanceType<typeof SearchMenu>>();
const avatarIsOssId = computed(() => {
const avatar = userStore.avatar;
if (!avatar) {
return false;
}
const isHttp = /^https?:\/\//i.test(avatar);
const isAbsolutePath = avatar.startsWith('/');
return !isHttp && !isAbsolutePath;
});
const openSearchMenu = () => {
searchMenuRef.value?.openSearch();
+7 -1
View File
@@ -13,6 +13,7 @@ export const useUserStore = defineStore('user', () => {
const userId = ref<string | number>('');
const tenantId = ref<string>('');
const avatar = ref('');
const avatarUrl = ref('');
const deptName = ref('');
const phonenumber = ref('');
const roles = ref<Array<string>>([]); // 用户角色编码集合 → 判断路由权限
@@ -40,7 +41,9 @@ export const useUserStore = defineStore('user', () => {
if (res) {
const data = res.data;
const user = data.user;
const profile = user.avatar == '' || user.avatar == null ? defAva : user.avatar;
const avatarValue = user.avatar === '' || user.avatar === null || user.avatar === undefined ? '' : String(user.avatar);
const previewUrl = user.avatarUrl || '';
const profile = avatarValue || defAva;
if (data.roles && data.roles.length > 0) {
// 验证返回的roles是否是一个非空数组
@@ -52,6 +55,7 @@ export const useUserStore = defineStore('user', () => {
name.value = user.userName;
nickname.value = user.nickName;
avatar.value = profile;
avatarUrl.value = previewUrl || (avatarValue ? '' : defAva);
userId.value = user.userId;
tenantId.value = user.tenantId;
deptName.value = user.deptName;
@@ -72,6 +76,7 @@ export const useUserStore = defineStore('user', () => {
const setAvatar = (value: string) => {
avatar.value = value;
avatarUrl.value = value;
};
return {
@@ -81,6 +86,7 @@ export const useUserStore = defineStore('user', () => {
name,
nickname,
avatar,
avatarUrl,
deptName,
phonenumber,
roles,
@@ -2,7 +2,7 @@
<div class="user-info-head" @click="editCropper()">
<img :src="options.img" title="点击上传头像" class="img-circle img-lg" />
<el-dialog v-model="open" :title="title" width="800px" append-to-body @opened="modalOpened" @close="closeDialog">
<el-row>
<el-row justify="center">
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<vue-cropper
v-if="visible"
@@ -17,40 +17,23 @@
@real-time="realTime"
/>
</el-col>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<div class="avatar-upload-preview">
<img :src="options.previews.url" :style="options.previews.img" />
</div>
</el-col>
</el-row>
<br />
<el-row>
<el-col :lg="2" :md="2">
<el-upload action="#" :http-request="requestUpload" :show-file-list="false" :before-upload="beforeUpload">
<el-button>
选择
<el-icon class="el-icon--right">
<Upload />
</el-icon>
</el-button>
</el-upload>
</el-col>
<el-col :lg="{ span: 1, offset: 2 }" :md="2">
<el-button icon="Plus" @click="changeScale(1)"></el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="Minus" @click="changeScale(-1)"></el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="RefreshLeft" @click="rotateLeft()"></el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="RefreshRight" @click="rotateRight()"></el-button>
</el-col>
<el-col :lg="{ span: 2, offset: 6 }" :md="2">
<el-button type="primary" @click="uploadImg()">提 交</el-button>
</el-col>
</el-row>
<div class="avatar-actions">
<el-upload action="#" :http-request="requestUpload" :show-file-list="false" :before-upload="beforeUpload">
<el-button>
选择
<el-icon class="el-icon--right">
<Upload />
</el-icon>
</el-button>
</el-upload>
<el-button icon="Plus" @click="changeScale(1)"></el-button>
<el-button icon="Minus" @click="changeScale(-1)"></el-button>
<el-button icon="RefreshLeft" @click="rotateLeft()"></el-button>
<el-button icon="RefreshRight" @click="rotateRight()"></el-button>
<el-button type="primary" @click="uploadImg()">提 交</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -84,13 +67,13 @@ const title = ref('修改头像');
const cropper = ref<any>({});
//图片裁剪数据
const options = reactive<Options>({
img: userStore.avatar,
img: userStore.avatarUrl || userStore.avatar,
autoCrop: true,
autoCropWidth: 200,
autoCropHeight: 200,
fixedBox: true,
outputType: 'png',
fileName: '',
fileName: 'avatar.png',
previews: {},
visible: false
});
@@ -134,14 +117,20 @@ const beforeUpload = (file: UploadRawFile): any => {
/** 上传图片 */
const uploadImg = async () => {
cropper.value.getCropBlob(async (data: any) => {
const formData = new FormData();
formData.append('avatarfile', data, options.fileName);
const res = await uploadAvatar(formData);
open.value = false;
options.img = res.data.imgUrl;
userStore.setAvatar(options.img);
proxy?.$modal.msgSuccess('修改成功');
visible.value = false;
try {
const formData = new FormData();
const fileName = options.fileName || `avatar.${options.outputType || 'png'}`;
formData.append('avatarfile', data, fileName);
const res = await uploadAvatar(formData);
await userStore.getInfo(); // 刷新用户信息以获取最新的oss_id
options.img = res?.data?.imgUrl || userStore.avatarUrl || userStore.avatar;
open.value = false;
proxy?.$modal.msgSuccess('修改成功');
} catch (error) {
proxy?.$modal.msgError('上传失败,请重试');
} finally {
visible.value = false;
}
});
};
/** 实时预览 */
@@ -150,7 +139,7 @@ const realTime = (data: any) => {
};
/** 关闭窗口 */
const closeDialog = () => {
options.img = userStore.avatar;
options.img = userStore.avatarUrl || userStore.avatar;
options.visible = false;
};
</script>
@@ -179,4 +168,12 @@ const closeDialog = () => {
line-height: 110px;
border-radius: 50%;
}
.avatar-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 12px;
}
</style>