Compare commits

..

5 Commits

Author SHA1 Message Date
Leo
6836bc59b7 feat(personal): 优化个人中心头像上传功能
- 改进头像URL处理逻辑,智能识别相对路径和完整URL
- 兼容不同存储服务的返回格式(本地存储、MinIO、OSS)
- 本地存储返回相对路径时自动添加服务地址前缀
- MinIO和OSS返回完整URL时直接使用,无需额外处理
- 提升多存储服务环境下的用户体验
2025-09-22 23:01:09 +08:00
Leo
821b80d09a feat(picture): 优化图片管理页面功能
- 新增MinIO存储服务选项支持
- 集成CoiImageViewer组件替换原生NImage组件
- 改进网格视图和表格视图的图片展示效果
- 移除冗余的图片错误处理函数,使用组件内置处理
- 统一存储服务类型的标签显示样式
- 提升图片预览和下载功能的用户体验
2025-09-22 23:00:10 +08:00
Leo
a6dc2f90ae feat(file): 优化文件管理页面功能
- 新增MinIO存储服务选项支持
- 集成CoiImageViewer组件替换原生图片预览功能
- 移除冗余的图片预览弹框代码,提升代码简洁性
- 优化图片文件的展示效果和预览体验
- 统一存储服务类型的标签显示样式
2025-09-22 22:58:57 +08:00
Leo
0d54a2d119 feat(types): 增加MinIO存储服务支持
- 在文件管理模块新增MinIO对象存储类型枚举和选项
- 在图片管理模块新增MinIO对象存储类型枚举和选项
- 完善存储服务类型的数据库映射配置
- 为后续MinIO存储功能集成做准备
2025-09-22 22:57:52 +08:00
Leo
cb4ebc8c14 feat(components): 新增CoiImageViewer图片查看器组件
- 新增通用图片查看器组件,支持缩略图预览和大图查看功能
- 支持自定义缩略图尺寸和预览弹框标题
- 集成图片下载和链接复制功能
- 实现加载状态和错误处理机制
- 采用现代化UI设计,支持悬停效果和动画过渡
2025-09-22 22:56:53 +08:00
6 changed files with 439 additions and 62 deletions

View File

@ -0,0 +1,395 @@
<template>
<div class="coi-image-viewer">
<!-- 缩略图展示 -->
<div
class="thumbnail-container"
:style="{ width: `${width}px`, height: `${height}px` }"
@click="handlePreview"
>
<img
v-if="!imageError && src"
:src="src"
:alt="alt || 'image'"
class="thumbnail-image"
@load="handleImageLoad"
@error="handleImageError"
>
<div v-else class="error-placeholder">
<NIcon size="16" class="error-icon">
<IconParkOutlinePic />
</NIcon>
<span class="error-text">加载失败</span>
</div>
<!-- 悬停遮罩层 -->
<div class="hover-overlay">
<NIcon size="18" class="preview-icon">
<IconParkOutlinePreviewOpen />
</NIcon>
<span class="preview-text">点击预览</span>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<NSpin size="small" />
</div>
</div>
<!-- 大图预览弹框 -->
<NModal
v-model:show="previewVisible"
preset="card"
:title="modalTitle"
:style="{ width: '90%', maxWidth: '1200px' }"
:mask-closable="true"
:closable="true"
:auto-focus="false"
:trap-focus="false"
>
<div class="preview-container">
<img
v-if="!previewError"
:src="src"
:alt="alt || 'preview'"
class="preview-image"
@load="handlePreviewLoad"
@error="handlePreviewError"
>
<div v-else class="preview-error">
<NIcon size="48" class="error-icon">
<IconParkOutlinePic />
</NIcon>
<p class="error-message">
图片加载失败
</p>
<p class="error-url">
{{ src }}
</p>
</div>
<!-- 预览加载状态 -->
<div v-if="previewLoading" class="preview-loading">
<NSpin size="large" />
<p class="loading-text">
图片加载中...
</p>
</div>
</div>
<!-- 弹框底部操作 -->
<template #action>
<NSpace justify="space-between">
<NSpace>
<NButton size="small" @click="handleDownload">
<template #icon>
<NIcon><IconParkOutlineDownload /></NIcon>
</template>
下载图片
</NButton>
<NButton size="small" @click="handleCopyUrl">
<template #icon>
<NIcon><IconParkOutlineCopy /></NIcon>
</template>
复制链接
</NButton>
</NSpace>
<NButton type="primary" @click="previewVisible = false">
关闭
</NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
<script setup lang="ts">
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
import IconParkOutlinePic from '~icons/icon-park-outline/pic'
import IconParkOutlinePreviewOpen from '~icons/icon-park-outline/preview-open'
import IconParkOutlineDownload from '~icons/icon-park-outline/download'
import IconParkOutlineCopy from '~icons/icon-park-outline/copy'
interface Props {
/** 图片链接 */
src: string | undefined
/** 图片描述 */
alt?: string
/** 缩略图宽度 */
width?: number
/** 缩略图高度 */
height?: number
/** 是否可以预览 */
previewable?: boolean
/** 预览弹框标题 */
title?: string
}
const props = withDefaults(defineProps<Props>(), {
alt: '',
width: 50,
height: 40,
previewable: true,
title: '图片预览',
})
//
const loading = ref(true)
const imageError = ref(false)
//
const previewVisible = ref(false)
const previewLoading = ref(false)
const previewError = ref(false)
//
const modalTitle = computed(() => {
return props.title || props.alt || '图片预览'
})
//
function handleImageLoad() {
loading.value = false
imageError.value = false
}
//
function handleImageError() {
loading.value = false
imageError.value = true
}
//
function handlePreview() {
if (!props.previewable || imageError.value) {
return
}
previewVisible.value = true
previewLoading.value = true
previewError.value = false
}
//
function handlePreviewLoad() {
previewLoading.value = false
previewError.value = false
}
//
function handlePreviewError() {
previewLoading.value = false
previewError.value = true
}
//
async function handleDownload() {
try {
const link = document.createElement('a')
link.href = props.src
link.download = props.alt || 'image'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
coiMsgSuccess('下载已开始')
}
catch {
coiMsgError('下载失败,请重试')
}
}
//
async function handleCopyUrl() {
try {
await navigator.clipboard.writeText(props.src)
coiMsgSuccess('链接已复制到剪贴板')
}
catch {
coiMsgError('复制失败,请手动复制')
}
}
// src
watch(
() => props.src,
() => {
loading.value = true
imageError.value = false
previewError.value = false
},
{ immediate: true },
)
</script>
<style scoped>
.coi-image-viewer {
display: inline-block;
}
.thumbnail-container {
position: relative;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: var(--card-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.thumbnail-container:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.thumbnail-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.thumbnail-container:hover .thumbnail-image {
transform: scale(1.05);
}
/* 当组件被设置为100%宽高时的样式调整 */
.coi-image-viewer.w-full.h-full .thumbnail-container {
width: 100% !important;
height: 100% !important;
border-radius: 0;
}
.error-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--card-color);
color: var(--text-color-3);
border: 1px dashed var(--border-color);
}
.error-icon {
margin-bottom: 4px;
color: var(--text-color-3);
}
.error-text {
font-size: 10px;
color: var(--text-color-3);
}
.hover-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
color: white;
}
.thumbnail-container:hover .hover-overlay {
opacity: 1;
}
.preview-icon {
margin-bottom: 4px;
}
.preview-text {
font-size: 10px;
font-weight: 500;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--card-color);
display: flex;
align-items: center;
justify-content: center;
}
.preview-container {
position: relative;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
background: var(--body-color);
border-radius: 8px;
overflow: hidden;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 4px;
}
.preview-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-color-3);
text-align: center;
padding: 40px 20px;
}
.error-message {
font-size: 16px;
margin: 16px 0 8px 0;
color: var(--text-color-2);
}
.error-url {
font-size: 12px;
color: var(--text-color-3);
word-break: break-all;
max-width: 500px;
}
.preview-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--modal-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loading-text {
margin-top: 16px;
color: var(--text-color-2);
font-size: 14px;
}
/* 响应式适配 */
@media (max-width: 768px) {
.preview-container {
min-height: 200px;
}
.preview-image {
max-height: 50vh;
}
}
</style>

View File

@ -59,12 +59,14 @@ export interface FileUploadResult {
// 文件服务类型枚举
export enum FileServiceType {
LOCAL = 'LOCAL', // 本地存储
MINIO = 'MINIO', // MinIO对象存储
OSS = 'OSS', // 阿里云对象存储
}
// 文件服务类型映射(用于数据库存储)
export const FileServiceTypeMapping = {
LOCAL: '1',
MINIO: '2',
OSS: '3',
} as const
@ -95,11 +97,13 @@ export const FILE_TYPE_OPTIONS = [
// 文件服务类型选项
export const FILE_SERVICE_OPTIONS = [
{ label: '本地存储', value: FileServiceType.LOCAL },
{ label: 'MinIO存储', value: FileServiceType.MINIO },
{ label: '阿里云OSS', value: FileServiceType.OSS },
]
// 文件服务类型数据库值选项(用于搜索)
export const FILE_SERVICE_DB_OPTIONS = [
{ label: '本地存储', value: '1' },
{ label: 'MinIO存储', value: '2' },
{ label: '阿里云OSS', value: '3' },
]

View File

@ -59,6 +59,7 @@ export interface PictureUploadResult {
// 图片服务类型枚举
export enum PictureServiceType {
LOCAL = '1', // 本地存储
MINIO = '2', // MinIO对象存储
OSS = '3', // 阿里云对象存储
}
@ -89,5 +90,6 @@ export const PICTURE_TYPE_OPTIONS = [
// 图片服务类型选项
export const PICTURE_SERVICE_OPTIONS = [
{ label: '本地存储', value: PictureServiceType.LOCAL },
{ label: 'MinIO存储', value: PictureServiceType.MINIO },
{ label: '阿里云OSS', value: PictureServiceType.OSS },
]

View File

@ -433,9 +433,15 @@ async function handleAvatarChange(event: Event) {
uploading.value = true
const result = await uploadAvatar(file, 2)
if (result.isSuccess) {
// 使访URL
const baseUrl = serviceConfig[import.meta.env.MODE].url
const avatarUrl = `${baseUrl}${result.data.fileUploadPath}`
// 访URL
let avatarUrl = result.data.fileUploadPath
// URL
if (!avatarUrl.startsWith('http://') && !avatarUrl.startsWith('https://')) {
const baseUrl = serviceConfig[import.meta.env.MODE].url
avatarUrl = `${baseUrl}${avatarUrl}`
}
// URLMinIOOSS使
basicForm.value.avatar = avatarUrl
personalData.value.avatar = avatarUrl

View File

@ -217,6 +217,7 @@
:options="[
{ label: 'LOCAL', value: 'LOCAL' },
{ label: 'OSS', value: 'OSS' },
{ label: 'MINIO', value: 'MINIO' },
]"
/>
</n-form-item>
@ -271,36 +272,16 @@
</div>
</template>
</CoiDialog>
<!-- 图片预览弹框 -->
<n-modal
v-model:show="imagePreviewVisible"
preset="card"
title="图片预览"
:style="{ width: '80%', maxWidth: '800px' }"
:mask-closable="true"
>
<div class="text-center">
<img
v-if="previewImageUrl"
:src="previewImageUrl"
:alt="previewImageName"
class="max-w-full max-h-96 mx-auto"
>
<div class="mt-2 text-sm text-gray-500">
{{ previewImageName }}
</div>
</div>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { h, onMounted, ref } from 'vue'
import { NButton, NIcon, NImage, NP, NPopconfirm, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
import { NButton, NIcon, NP, NPopconfirm, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
import type { DataTableColumns, DataTableInst, FormInst, UploadFileInfo, UploadInst } from 'naive-ui'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import CoiImageViewer from '@/components/common/CoiImageViewer.vue'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { usePermission } from '@/hooks/usePermission'
import { PERMISSIONS } from '@/constants/permissions'
@ -360,11 +341,6 @@ const searchForm = ref<SysFileSearchForm>({
fileType: '0', //
})
//
const imagePreviewVisible = ref(false)
const previewImageUrl = ref('')
const previewImageName = ref('')
//
const uploadFileList = ref<UploadFileInfo[]>([])
const uploading = ref(false)
@ -458,14 +434,13 @@ const columns: DataTableColumns<SysFileVo> = [
)
if (isImage && row.filePath) {
return h(NImage, {
return h(CoiImageViewer, {
src: row.filePath,
width: 45,
height: 30,
objectFit: 'cover',
previewDisabled: true,
onClick: () => handleImagePreview(row.filePath!, row.fileName),
class: 'cursor-pointer rounded border',
alt: row.fileName,
width: 50,
height: 40,
previewable: true,
title: `文件预览 - ${row.fileName}`,
})
}
@ -481,6 +456,7 @@ const columns: DataTableColumns<SysFileVo> = [
render: (row) => {
const serviceMap: Record<string, { type: 'success' | 'info' | 'warning', text: string }> = {
1: { type: 'success', text: '本地存储' },
2: { type: 'info', text: 'MinIO存储' },
3: { type: 'warning', text: '阿里云OSS' },
}
const config = serviceMap[row.fileService] || { type: 'info', text: '未知' }
@ -676,13 +652,6 @@ function handleDownload(row: SysFileVo) {
}
}
//
function handleImagePreview(url: string, name: string) {
previewImageUrl.value = url
previewImageName.value = name
imagePreviewVisible.value = true
}
//
//

View File

@ -191,13 +191,16 @@
<template #trigger>
<div class="picture-card group relative bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow cursor-pointer">
<!-- 图片预览 -->
<div class="aspect-square relative overflow-hidden bg-gray-100">
<img
<div class="aspect-square relative overflow-hidden bg-gray-100 flex items-center justify-center">
<CoiImageViewer
:src="picture.picturePath"
:alt="picture.pictureName"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
@error="handleImageError"
>
:width="200"
:height="200"
:previewable="true"
:title="`图片预览 - ${picture.pictureName}`"
class="w-full h-full"
/>
</div>
<!-- 图片信息 -->
@ -344,6 +347,7 @@
:options="[
{ label: 'LOCAL', value: 'LOCAL' },
{ label: 'OSS', value: 'OSS' },
{ label: 'MINIO', value: 'MINIO' },
]"
/>
</n-form-item>
@ -402,11 +406,12 @@
<script setup lang="ts">
import { h, onMounted, ref } from 'vue'
import { NButton, NIcon, NImage, NP, NPopconfirm, NSpace, NSpin, NTag, NText, NTooltip, NUpload, NUploadDragger } from 'naive-ui'
import { NButton, NIcon, NP, NPopconfirm, NSpace, NSpin, NTag, NText, NTooltip, NUpload, NUploadDragger } from 'naive-ui'
import type { DataTableColumns, DataTableInst, FormInst, UploadFileInfo, UploadInst } from 'naive-ui'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import CoiImageViewer from '@/components/common/CoiImageViewer.vue'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { usePermission } from '@/hooks/usePermission'
import { PERMISSIONS } from '@/constants/permissions'
@ -570,13 +575,13 @@ const columns: DataTableColumns<SysPictureVo> = [
},
render: (row) => {
if (row.picturePath) {
return h(NImage, {
return h(CoiImageViewer, {
src: row.picturePath,
width: 45,
height: 30,
objectFit: 'cover',
previewDisabled: true,
class: 'rounded border',
alt: row.pictureName,
width: 50,
height: 40,
previewable: true,
title: `图片预览 - ${row.pictureName}`,
})
}
return '--'
@ -590,6 +595,7 @@ const columns: DataTableColumns<SysPictureVo> = [
render: (row) => {
const serviceMap: Record<string, { type: 'success' | 'info' | 'warning', text: string }> = {
1: { type: 'success', text: '本地存储' },
2: { type: 'info', text: 'MinIO存储' },
3: { type: 'warning', text: '阿里云OSS' },
}
const config = serviceMap[row.pictureService] || { type: 'info', text: '未知' }
@ -795,16 +801,11 @@ function handleDownload(row: SysPictureVo) {
}
}
//
function handleImageError(e: Event) {
const img = e.target as HTMLImageElement
img.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiBmaWxsPSIjRjVGNUY1Ii8+CjxwYXRoIGQ9Ik0yMCAyMEw0NCA0NEwyMCA0NFYyMFoiIGZpbGw9IiNEOUQ5RDkiLz4KPGNpcmNsZSBjeD0iMjgiIGN5PSIyOCIgcj0iNCIgZmlsbD0iI0Q5RDlEOSIvPgo8L3N2Zz4K'
}
//
function getPictureServiceText(serviceType: string): string {
const serviceMap: Record<string, string> = {
1: '本地存储',
2: 'MinIO存储',
3: '阿里云OSS',
}
return serviceMap[serviceType] || '未知'