修复用户和文件管理,增加文件预览

This commit is contained in:
2025-11-03 16:18:30 +08:00
parent 2023fe4b97
commit 88d2f527f4
22 changed files with 4821 additions and 1095 deletions
+39
View File
@@ -98,3 +98,42 @@ export function searchFiles(keyword) {
params: { keyword },
});
}
/**
* 下载文件
* @param {number|string} id 文件ID
* @returns {string} 下载URL
*/
export function downloadFile(id) {
return `/api/files/download/${id}`;
}
/**
* 预览文件
* @param {number|string} id 文件ID
* @returns {string} 预览URL
*/
export function previewFile(id) {
return `/api/files/preview/${id}`;
}
/**
* 检查文件是否可预览
* @param {string} fileExt 文件扩展名
* @returns {boolean}
*/
export function canPreview(fileExt) {
const previewableExts = [
// 图片格式
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg',
// 文档格式
'.pdf', '.txt',
// Office文档格式(仅支持 .docx,不支持旧的 .doc、Excel、PPT
'.docx',
// 视频格式
'.mp4', '.webm',
// 音频格式
'.mp3', '.wav'
];
return previewableExts.includes(fileExt.toLowerCase());
}
+8
View File
@@ -16,6 +16,14 @@ export function getUserInfo(userId) {
});
}
//获取租户用户信息
export function getTenantUsers(tenantId) {
return request({
url: `/api/tenantUsers/${tenantId}`,
method: 'get',
});
}
// 添加用户
export function addUser(data) {
return request({
+35 -1
View File
@@ -4,7 +4,7 @@ import CommonHeader from '@/components/CommonHeader.vue';
import { useTabsStore } from '@/stores';
import { useRouter, useRoute } from 'vue-router';
import { ref, watch, reactive, nextTick, onMounted, computed } from 'vue';
import { More, Close, CircleClose } from '@element-plus/icons-vue'
import { More, Close, CircleClose, ArrowUp } from '@element-plus/icons-vue'
const tabsStore = useTabsStore();
const router = useRouter();
@@ -451,6 +451,15 @@ const canCloseRight = computed(() => {
</keep-alive>
</router-view>
</div>
<!-- 回到顶部按钮 -->
<el-backtop :target="'.right-main'" :visibility-height="300" :right="30" :bottom="50">
<div class="backtop-button">
<el-icon :size="20">
<ArrowUp />
</el-icon>
</div>
</el-backtop>
</el-main>
</el-container>
</el-container>
@@ -606,6 +615,31 @@ const canCloseRight = computed(() => {
:deep(.el-menu){
border-right: none !important;
}
// 回到顶部按钮样式
.backtop-button {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-color-primary);
color: #fff;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(64, 129, 255, 0.4);
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: var(--el-color-primary-light-3);
box-shadow: 0 6px 16px rgba(64, 129, 255, 0.5);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
}
</style>
<style lang="less">
+431
View File
@@ -0,0 +1,431 @@
<template>
<div class="category-container">
<!-- 顶部操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加分类
</el-button>
<el-button @click="refreshCategories">
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
</div>
<!-- 分类列表 -->
<div class="category-list">
<el-table :data="categories" v-loading="loading" style="width: 100%" stripe>
<el-table-column type="index" label="#" width="60" />
<el-table-column prop="name" label="分类名称" min-width="150">
<template #default="{ row }">
<div class="category-name">
<el-tag :color="row.color" effect="dark" size="large">
{{ row.name }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
<el-table-column prop="file_count" label="文件数量" width="120" sortable>
<template #default="{ row }">
<el-badge :value="row.file_count || 0" :max="999" class="item">
<el-button size="small" text type="primary">
{{ row.file_count || 0 }} 个文件
</el-button>
</el-badge>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="100" sortable>
<template #default="{ row }">
<el-tag type="info">{{ row.sort_order || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="updateCategoryStatus(row)"
:disabled="loading"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="editCategory(row)">
编辑
</el-button>
<el-button size="small" type="danger" @click="deleteCategory(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 添加/编辑分类对话框 -->
<el-dialog
v-model="showAddDialog"
:title="editingCategory ? '编辑分类' : '添加分类'"
width="500px"
:before-close="handleDialogClose"
>
<el-form
ref="categoryFormRef"
:model="categoryForm"
:rules="categoryRules"
label-width="80px"
>
<el-form-item label="分类名称" prop="name">
<el-input v-model="categoryForm.name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="categoryForm.description"
type="textarea"
:rows="3"
placeholder="请输入分类描述"
/>
</el-form-item>
<el-form-item label="颜色" prop="color">
<el-color-picker v-model="categoryForm.color" show-alpha />
<span class="tip-text">选择分类显示颜色</span>
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number
v-model="categoryForm.sort_order"
:min="0"
:max="999"
placeholder="排序值,越小越靠前"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch
v-model="categoryForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleDialogClose"> </el-button>
<el-button type="primary" @click="saveCategory" :loading="saving">
{{ saving ? '保存中...' : '确 定' }}
</el-button>
</div>
</template>
</el-dialog>
<!-- 统计信息卡片 -->
<div class="stats-cards">
<el-row :gutter="16">
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-icon" style="background: #e6f7ff; color: #1890ff">
<el-icon><Grid /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">总分类数</div>
<div class="stat-value">{{ totalCategories }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-icon" style="background: #f0f9ff; color: #52c41a">
<el-icon><SuccessFilled /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">启用分类</div>
<div class="stat-value">{{ enabledCategories }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-icon" style="background: #fff7e6; color: #faad14">
<el-icon><Document /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">关联文件</div>
<div class="stat-value">{{ totalFiles }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-icon" style="background: #fff1f0; color: #f5222d">
<el-icon><CircleClose /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">禁用分类</div>
<div class="stat-value">{{ disabledCategories }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
Plus,
RefreshRight,
Grid,
Document,
SuccessFilled,
CircleClose,
} from '@element-plus/icons-vue'
// Props & Emits
const emit = defineEmits(['category-change'])
// 响应式数据
const loading = ref(false)
const saving = ref(false)
const showAddDialog = ref(false)
const editingCategory = ref<any>(null)
const categoryFormRef = ref<FormInstance>()
const categories = ref([
{ id: 1, name: '文档', description: '各类文档文件', color: '#409eff', file_count: 15, sort_order: 1, status: 1 },
{ id: 2, name: '图片', description: '图片和照片', color: '#67c23a', file_count: 32, sort_order: 2, status: 1 },
{ id: 3, name: '视频', description: '视频文件', color: '#e6a23c', file_count: 8, sort_order: 3, status: 1 },
{ id: 4, name: '音频', description: '音频文件', color: '#f56c6c', file_count: 5, sort_order: 4, status: 1 },
{ id: 5, name: '编辑器', description: '编辑器上传的文件', color: '#909399', file_count: 28, sort_order: 5, status: 1 },
{ id: 6, name: '其他', description: '其他类型文件', color: '#606266', file_count: 12, sort_order: 6, status: 1 },
])
const categoryForm = reactive({
name: '',
description: '',
color: '#409eff',
sort_order: 0,
status: 1,
})
const categoryRules = reactive<FormRules>({
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' },
],
description: [
{ max: 200, message: '描述不能超过 200 个字符', trigger: 'blur' },
],
})
// 计算属性
const totalCategories = computed(() => categories.value.length)
const enabledCategories = computed(() => categories.value.filter((c) => c.status === 1).length)
const disabledCategories = computed(() => categories.value.filter((c) => c.status === 0).length)
const totalFiles = computed(() => categories.value.reduce((sum, c) => sum + (c.file_count || 0), 0))
// 方法
const refreshCategories = () => {
loading.value = true
// 这里应该调用API获取分类列表
setTimeout(() => {
loading.value = false
ElMessage.success('刷新成功')
}, 500)
}
const editCategory = (row: any) => {
editingCategory.value = row
categoryForm.name = row.name
categoryForm.description = row.description || ''
categoryForm.color = row.color || '#409eff'
categoryForm.sort_order = row.sort_order || 0
categoryForm.status = row.status
showAddDialog.value = true
}
const deleteCategory = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要删除分类「${row.name}」吗?该分类下有 ${row.file_count || 0} 个文件,删除后文件将归入"未分类"。`,
'警告',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
}
)
loading.value = true
// 这里应该调用API删除分类
setTimeout(() => {
const index = categories.value.findIndex((c) => c.id === row.id)
if (index > -1) {
categories.value.splice(index, 1)
}
loading.value = false
ElMessage.success('删除成功')
emit('category-change')
}, 500)
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.message || '删除失败')
}
}
}
const updateCategoryStatus = (row: any) => {
loading.value = true
// 这里应该调用API更新状态
setTimeout(() => {
loading.value = false
ElMessage.success(row.status === 1 ? '已启用' : '已禁用')
emit('category-change')
}, 300)
}
const saveCategory = async () => {
if (!categoryFormRef.value) return
await categoryFormRef.value.validate(async (valid) => {
if (!valid) return
saving.value = true
try {
// 这里应该调用API保存分类
await new Promise((resolve) => setTimeout(resolve, 500))
if (editingCategory.value) {
// 更新现有分类
Object.assign(editingCategory.value, categoryForm)
ElMessage.success('更新成功')
} else {
// 添加新分类
const newCategory = {
id: categories.value.length + 1,
...categoryForm,
file_count: 0,
}
categories.value.push(newCategory)
ElMessage.success('添加成功')
}
showAddDialog.value = false
resetForm()
emit('category-change')
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
saving.value = false
}
})
}
const handleDialogClose = () => {
showAddDialog.value = false
resetForm()
}
const resetForm = () => {
editingCategory.value = null
categoryForm.name = ''
categoryForm.description = ''
categoryForm.color = '#409eff'
categoryForm.sort_order = 0
categoryForm.status = 1
categoryFormRef.value?.resetFields()
}
// 生命周期
onMounted(() => {
refreshCategories()
})
</script>
<style scoped>
.category-container {
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
min-height: 500px;
}
.action-bar {
margin-bottom: 20px;
display: flex;
gap: 12px;
}
.category-list {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.category-name {
display: flex;
align-items: center;
}
.stats-cards {
margin-top: 20px;
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.stat-content {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
.tip-text {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
}
</style>
+909
View File
@@ -0,0 +1,909 @@
<template>
<div class="file-list-container">
<!-- 搜索和筛选栏 -->
<div class="search-filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索原始文件名"
clearable
@input="handleSearch"
size="large"
class="search-input"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="filterCategory"
placeholder="全部分类"
clearable
@change="applyFilters"
size="large"
class="category-select"
>
<el-option
v-for="category in categories"
:key="category"
:label="category"
:value="category"
/>
</el-select>
<el-button
type="primary"
size="large"
@click="showUploadDialog = true"
>
<el-icon><UploadFilled /></el-icon>
快速上传
</el-button>
<el-button
size="large"
@click="fetchFiles"
>
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
</div>
<!-- 文件表格 -->
<div class="file-table">
<el-table
:data="paginatedFiles"
v-loading="loading"
style="width: 100%"
stripe
:default-sort="{ prop: 'upload_time', order: 'descending' }"
>
<el-table-column prop="id" label="ID" width="80" sortable />
<el-table-column label="文件名" min-width="250">
<template #default="{ row }">
<div class="file-name">
<el-icon class="icon-doc"><Document /></el-icon>
<span>{{ row.original_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="file_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.file_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="大小" width="120" sortable>
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="120">
<template #default="{ row }">
<el-tag type="success" effect="plain">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="upload_by" label="上传人" width="120" />
<el-table-column prop="upload_time" label="上传时间" width="180" sortable>
<template #default="{ row }">
{{ formatDate(row.upload_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-tooltip :content="canPreviewFile(row.file_ext) ? '预览' : '下载'">
<el-button
:type="canPreviewFile(row.file_ext) ? 'success' : 'warning'"
circle
plain
size="small"
@click="handleFileAction(row)"
>
<el-icon>
<View v-if="canPreviewFile(row.file_ext)" />
<Download v-else />
</el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除">
<el-button
circle
type="danger"
plain
size="small"
@click="deleteFile(row)"
>
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页器 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalFiles"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 文件预览对话框 -->
<el-dialog
v-model="showPreviewDialog"
:width="isFullscreen ? '100%' : '80%'"
:fullscreen="isFullscreen"
:before-close="handlePreviewClose"
class="preview-dialog"
>
<template #header>
<div class="preview-dialog-header">
<span class="preview-dialog-title">{{ previewFile?.original_name || '文件预览' }}</span>
<el-button
:icon="isFullscreen ? 'FullScreen' : 'FullScreen'"
circle
size="small"
@click="toggleFullscreen"
:title="isFullscreen ? '退出全屏' : '全屏'"
>
<el-icon v-if="isFullscreen"><DCaret /></el-icon>
<el-icon v-else><FullScreen /></el-icon>
</el-button>
</div>
</template>
<div v-if="previewFile" class="preview-content">
<!-- 图片预览 -->
<div v-if="isImageFile(previewFile.file_ext)" class="preview-image-wrapper">
<el-image
:src="previewImageUrl"
fit="contain"
:preview-src-list="[previewImageUrl]"
:hide-on-click-modal="true"
:style="{ width: '100%', maxHeight: isFullscreen ? 'calc(100vh - 140px)' : '70vh' }"
loading="lazy"
/>
</div>
<!-- PDF预览 -->
<div v-else-if="isPdfFile(previewFile.file_ext)" :class="['preview-pdf-wrapper', { 'is-fullscreen': isFullscreen }]">
<VuePdfApp v-if="previewImageUrl" :pdf="previewImageUrl" />
</div>
<!-- Word文档预览 -->
<div v-else-if="isWordFile(previewFile.file_ext)" :class="['preview-word-wrapper', { 'is-fullscreen': isFullscreen }]">
<div ref="wordPreviewContainer" class="word-preview-container"></div>
</div>
<!-- 其他Office文档Excel, PPT暂不支持 -->
<div v-else-if="isOfficeFile(previewFile.file_ext)" class="preview-other">
<el-icon style="font-size: 64px; color: #909399"><Document /></el-icon>
<p>{{ getOfficeFileTypeName(previewFile.file_ext) }} 文件暂不支持在线预览</p>
<el-button type="primary" @click="downloadFile(previewFile)">
<el-icon><Download /></el-icon>
下载文件
</el-button>
</div>
<!-- 其他文件显示提示 -->
<div v-else class="preview-other">
<el-icon style="font-size: 64px; color: #909399"><Document /></el-icon>
<p>该文件类型不支持在线预览</p>
<el-button type="primary" @click="downloadFile(previewFile)">
<el-icon><Download /></el-icon>
下载文件
</el-button>
</div>
</div>
</el-dialog>
<!-- 快速上传对话框 -->
<el-dialog
v-model="showUploadDialog"
title="快速上传"
width="500px"
:before-close="handleUploadClose"
>
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ category: uploadForm.category }"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
multiple
>
<el-icon class="el-icon--upload" style="font-size: 44px; color: #36b2fa">
<UploadFilled />
</el-icon>
<div class="el-upload__text">
将文件拖到此处<em style="color: #409eff">点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持批量上传单文件大小不超过
<span style="color: #f56c6c; font-weight: 600">10MB</span>
</div>
</template>
</el-upload>
<div class="upload-form">
<el-form :model="uploadForm" label-width="80px">
<el-form-item label="分类">
<el-select v-model="uploadForm.category" placeholder="请选择分类" style="width: 100%">
<el-option label="文档" value="文档" />
<el-option label="图片" value="图片" />
<el-option label="视频" value="视频" />
<el-option label="音频" value="音频" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-form>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
UploadFilled,
RefreshRight,
Document,
View,
Download,
Delete,
WarningFilled,
FullScreen,
DCaret,
} from '@element-plus/icons-vue'
import {
getFilesByTenant,
deleteFile as deleteFileApi,
canPreview as canPreviewApi,
previewFile as previewFileApi,
downloadFile as downloadFileApi,
} from '@/api/file'
import { useAuthStore } from '@/stores/auth'
import { renderAsync } from 'docx-preview'
import VuePdfApp from 'vue3-pdf-app'
import 'vue3-pdf-app/dist/icons/main.css'
// Props & Emits
const emit = defineEmits(['refresh'])
// Store
const authStore = useAuthStore()
// 响应式数据
const allFiles = ref<any[]>([])
const categories = ref<string[]>([])
const filteredFiles = ref<any[]>([])
const loading = ref(false)
const searchKeyword = ref('')
const filterCategory = ref('')
const pageSize = ref(10)
const currentPage = ref(1)
const totalFiles = ref(0)
const showUploadDialog = ref(false)
const showPreviewDialog = ref(false)
const previewFile = ref<any>(null)
const previewImageUrl = ref('')
const wordPreviewContainer = ref<HTMLElement | null>(null)
const isFullscreen = ref(false)
const uploadForm = ref({
category: '文档',
})
// 计算属性
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL
return `${baseUrl}/api/files`
})
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token')
return {
Authorization: `Bearer ${token}`,
}
})
const paginatedFiles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredFiles.value.slice(start, end)
})
// 获取当前租户ID
const getCurrentTenantId = () => {
if (authStore.user && authStore.user.tenant_id) {
return authStore.user.tenant_id
}
const userInfo = localStorage.getItem('userInfo')
if (userInfo) {
try {
const user = JSON.parse(userInfo)
return user.tenant_id || user.tenantId || 1
} catch (e) {
console.error('Failed to parse user info:', e)
}
}
return 1
}
// 获取文件列表
const fetchFiles = async () => {
loading.value = true
try {
const tenantId = getCurrentTenantId()
const res = await getFilesByTenant(tenantId)
if (res.success && res.data) {
const files = Array.isArray(res.data) ? res.data : []
allFiles.value = files.map((file: any) => ({
...file,
upload_time: file.upload_time || file.create_time,
}))
// 提取所有分类
const uniqueCategories = new Set<string>()
files.forEach((file: any) => {
if (file.category) {
uniqueCategories.add(file.category)
}
})
categories.value = Array.from(uniqueCategories).sort()
applyFilters()
totalFiles.value = filteredFiles.value.length
emit('refresh')
} else {
allFiles.value = []
totalFiles.value = 0
}
} catch (err: any) {
ElMessage.error(err.message || '获取文件列表失败')
allFiles.value = []
totalFiles.value = 0
} finally {
loading.value = false
}
}
// 应用搜索和筛选
const applyFilters = () => {
let result = [...allFiles.value]
if (filterCategory.value) {
result = result.filter((file) => file.category === filterCategory.value)
}
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(
(file) =>
file.original_name && file.original_name.toLowerCase().includes(keyword)
)
}
filteredFiles.value = result
totalFiles.value = result.length
if (currentPage.value > 1 && result.length === 0) {
currentPage.value = 1
}
}
// 搜索处理
const handleSearch = () => {
applyFilters()
}
// 分页处理
const handleSizeChange = (val: number) => {
pageSize.value = val
currentPage.value = 1
}
const handleCurrentChange = (val: number) => {
currentPage.value = val
}
// 上传相关
const handleUploadClose = () => {
showUploadDialog.value = false
uploadForm.value.category = '文档'
}
const beforeUpload = (file: File) => {
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB')
return false
}
return true
}
const handleUploadSuccess = (response: any, file: any) => {
if (response.success) {
ElMessage.success('文件上传成功!')
showUploadDialog.value = false
uploadForm.value.category = '文档'
fetchFiles()
} else {
ElMessage.error(response.message || '文件上传失败!')
}
}
const handleUploadError = (error: Error, file: any) => {
ElMessage.error('文件上传失败:' + (error.message || error))
}
// 文件操作
const canPreviewFile = (fileExt: string) => {
return canPreviewApi(fileExt)
}
const handleFileAction = (file: any) => {
if (canPreviewFile(file.file_ext)) {
previewFileAction(file)
} else {
downloadFile(file)
}
}
// 判断是否为图片文件
const isImageFile = (fileExt: string) => {
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
return imageExts.includes(fileExt.toLowerCase())
}
// 判断是否为PDF文件
const isPdfFile = (fileExt: string) => {
return fileExt.toLowerCase() === '.pdf'
}
// 判断是否为Office文档(Word, Excel, PPT
const isOfficeFile = (fileExt: string) => {
const officeExts = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']
return officeExts.includes(fileExt.toLowerCase())
}
// 判断是否为Word文档
const isWordFile = (fileExt: string) => {
const wordExts = ['.doc', '.docx']
return wordExts.includes(fileExt.toLowerCase())
}
// 判断是否为Excel文档
const isExcelFile = (fileExt: string) => {
const excelExts = ['.xls', '.xlsx']
return excelExts.includes(fileExt.toLowerCase())
}
// 判断是否为PPT文档
const isPptFile = (fileExt: string) => {
const pptExts = ['.ppt', '.pptx']
return pptExts.includes(fileExt.toLowerCase())
}
// 获取Office文件类型名称
const getOfficeFileTypeName = (fileExt: string) => {
if (isExcelFile(fileExt)) return 'Excel'
if (isPptFile(fileExt)) return 'PowerPoint'
return 'Office'
}
const previewFileAction = async (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
const previewUrl = `${baseUrl}${previewFileApi(file.id)}`
const token = localStorage.getItem('token')
if (!token) {
ElMessage.error('请先登录')
return
}
try {
loading.value = true
// 获取文件内容
const response = await fetch(previewUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
// 尝试解析错误信息
let errorMessage = '预览失败'
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
try {
const errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// 如果不是JSON,使用默认错误信息
}
}
ElMessage.error(errorMessage)
loading.value = false
return
}
const blob = await response.blob()
// Word 文档预览
if (isWordFile(file.file_ext)) {
previewFile.value = file
showPreviewDialog.value = true
// 等待 DOM 更新
await nextTick()
if (wordPreviewContainer.value) {
// 清空容器
wordPreviewContainer.value.innerHTML = ''
// 使用 docx-preview 渲染
try {
await renderAsync(blob, wordPreviewContainer.value, undefined, {
className: 'docx-wrapper',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
})
} catch (error: any) {
console.error('Word 预览失败:', error)
ElMessage.error('Word 预览失败: ' + (error.message || error))
showPreviewDialog.value = false
previewFile.value = null
}
}
}
// PDF 文档预览
else if (isPdfFile(file.file_ext)) {
const blobUrl = window.URL.createObjectURL(blob)
previewFile.value = file
previewImageUrl.value = blobUrl
showPreviewDialog.value = true
}
// 图片预览
else if (isImageFile(file.file_ext)) {
const blobUrl = window.URL.createObjectURL(blob)
previewFile.value = file
previewImageUrl.value = blobUrl
showPreviewDialog.value = true
}
// 其他Office文档(Excel, PPT- 暂不支持在线预览
else if (isOfficeFile(file.file_ext)) {
ElMessageBox.confirm(
`${getOfficeFileTypeName(file.file_ext)} 文件暂不支持在线预览,是否下载文件?`,
'提示',
{
confirmButtonText: '下载',
cancelButtonText: '取消',
type: 'info',
}
).then(() => {
downloadFile(file)
}).catch(() => {})
}
// 其他可预览文件(视频、音频等),在新窗口打开
else {
const blobUrl = window.URL.createObjectURL(blob)
const previewWindow = window.open(blobUrl, '_blank')
if (!previewWindow) {
ElMessage.warning('无法打开预览窗口,请检查浏览器弹窗设置')
window.URL.revokeObjectURL(blobUrl)
return
}
// 监听窗口关闭,清理blob URL
const checkWindow = setInterval(() => {
if (previewWindow.closed) {
window.URL.revokeObjectURL(blobUrl)
clearInterval(checkWindow)
}
}, 1000)
}
} catch (error: any) {
console.error('预览失败:', error)
ElMessage.error('预览失败: ' + (error.message || error))
} finally {
loading.value = false
}
}
const handlePreviewClose = () => {
// 清理blob URL
if (previewImageUrl.value && previewImageUrl.value.startsWith('blob:')) {
window.URL.revokeObjectURL(previewImageUrl.value)
}
// 清理 Word 预览容器
if (wordPreviewContainer.value) {
wordPreviewContainer.value.innerHTML = ''
}
showPreviewDialog.value = false
previewFile.value = null
previewImageUrl.value = ''
isFullscreen.value = false
}
// 切换全屏模式
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value
}
const downloadFile = (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
const downloadUrl = `${baseUrl}${downloadFileApi(file.id)}`
const token = localStorage.getItem('token')
const link = document.createElement('a')
link.href = downloadUrl
if (token) {
fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.blob())
.then((blob) => {
const url = window.URL.createObjectURL(blob)
link.href = url
link.download = file.original_name || file.file_name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('文件下载开始')
})
.catch((err) => {
ElMessage.error('文件下载失败:' + err.message)
})
} else {
link.download = file.original_name || file.file_name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
const deleteFile = async (file: any) => {
try {
await ElMessageBox.confirm(
`确定要删除文件「${file.original_name || file.file_name}」吗?删除后不可恢复。`,
'警告',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
}
)
loading.value = true
const res = await deleteFileApi(file.id)
if (res.success) {
ElMessage.success('删除成功')
await fetchFiles()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.message || '删除失败')
}
} finally {
loading.value = false
}
}
// 格式化函数
const formatFileSize = (bytes: number | string | undefined | null): string => {
if (!bytes && bytes !== 0) return '0 B'
const bytesValue = typeof bytes === 'string' ? parseFloat(bytes) : Number(bytes)
if (isNaN(bytesValue) || bytesValue < 0) return '0 B'
if (bytesValue === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytesValue) / Math.log(k))
return parseFloat((bytesValue / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatDate = (dateStr: string | number | undefined | null): string => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (isNaN(date.getTime())) return String(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
// 生命周期
onMounted(() => {
fetchFiles()
})
// 暴露方法给父组件
defineExpose({
fetchFiles,
})
</script>
<style scoped>
.file-list-container {
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
min-height: 500px;
}
.search-filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 300px;
}
.category-select {
width: 180px;
}
.file-table {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.file-name {
display: flex;
align-items: center;
gap: 8px;
}
.icon-doc {
color: #409eff;
font-size: 18px;
}
.pagination {
display: flex;
justify-content: flex-end;
padding: 20px;
background: #fff;
border-radius: 8px;
}
.upload-form {
margin-top: 20px;
border-top: 1px solid #eee;
padding-top: 20px;
}
.preview-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 20px;
}
.preview-dialog-title {
font-size: 18px;
font-weight: 600;
color: #303133;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-dialog :deep(.el-dialog__body) {
padding: 20px;
text-align: center;
}
.preview-dialog.is-fullscreen :deep(.el-dialog__body) {
height: calc(100vh - 120px);
overflow: auto;
}
.preview-content {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.preview-image-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
.preview-pdf-wrapper {
width: 100%;
height: 70vh;
}
.is-fullscreen .preview-pdf-wrapper {
height: calc(100vh - 140px);
}
.preview-word-wrapper {
width: 100%;
height: 70vh;
overflow: auto;
}
.is-fullscreen .preview-word-wrapper {
height: calc(100vh - 140px);
}
.word-preview-container {
width: 100%;
min-height: 70vh;
padding: 20px;
background: #fff;
}
.word-preview-container :deep(.docx-wrapper) {
background: #fff;
}
.word-preview-container :deep(.docx) {
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
margin: 0 auto;
background: #fff;
}
.preview-other {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 40px;
}
.preview-other p {
margin: 0;
color: #606266;
font-size: 16px;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 180px;
}
:deep(.el-button--small span,.el-button span){
margin-left: 0 !important;
}
</style>
+539
View File
@@ -0,0 +1,539 @@
<template>
<div class="upload-container">
<!-- 上传区域 -->
<div class="upload-area">
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ category: uploadForm.category }"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-progress="handleUploadProgress"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
multiple
:file-list="fileList"
:auto-upload="autoUpload"
>
<el-icon class="el-icon--upload" style="font-size: 64px; color: #409eff">
<UploadFilled />
</el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击选择文件</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持批量上传单个文件大小不超过
<span style="color: #f56c6c; font-weight: 600">10MB</span>
支持拖拽排序
</div>
</template>
</el-upload>
</div>
<!-- 上传设置 -->
<div class="upload-settings">
<el-card>
<template #header>
<div class="card-header">
<span>上传设置</span>
</div>
</template>
<el-form :model="uploadForm" label-width="100px">
<el-form-item label="文件分类">
<el-select v-model="uploadForm.category" placeholder="请选择分类">
<el-option label="文档" value="文档" />
<el-option label="图片" value="图片" />
<el-option label="视频" value="视频" />
<el-option label="音频" value="音频" />
<el-option label="编辑器" value="编辑器" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="自动上传">
<el-switch v-model="autoUpload" />
<span class="tip-text">开启后选择文件即自动上传</span>
</el-form-item>
</el-form>
</el-card>
</div>
<!-- 文件列表预览 -->
<div class="file-preview" v-if="fileList.length > 0">
<el-card>
<template #header>
<div class="card-header">
<span>待上传文件列表{{ fileList.length }}个文件</span>
<el-button
size="small"
type="danger"
@click="clearFiles"
:disabled="uploading"
>
清空列表
</el-button>
</div>
</template>
<el-table :data="fileList" style="width: 100%" max-height="400">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
<el-table-column label="大小" width="120">
<template #default="{ row }">
{{ formatFileSize(row.size) }}
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag size="small">{{ getFileType(row.name) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row, $index }">
<el-button
size="small"
type="danger"
@click="removeFile($index)"
:disabled="row.status === 'uploading' || uploading"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="upload-actions" v-if="!autoUpload">
<el-button
type="primary"
@click="submitUpload"
:loading="uploading"
:disabled="fileList.length === 0"
size="large"
>
<el-icon><Upload /></el-icon>
{{ uploading ? '上传中...' : '开始上传' }}
</el-button>
</div>
</el-card>
</div>
<!-- 上传进度 -->
<div class="upload-progress" v-if="uploading || uploadedCount > 0">
<el-card>
<template #header>
<span>上传进度</span>
</template>
<el-progress
:percentage="uploadProgress"
:status="uploadStatus"
:stroke-width="20"
/>
<div class="progress-info">
<span>已上传: {{ uploadedCount }}/{{ totalCount }}</span>
<span>成功: {{ successCount }} </span>
<span>失败: {{ failedCount }} </span>
<span>成功率: {{ successRate }}%</span>
</div>
</el-card>
</div>
<!-- 上传提示 -->
<div class="upload-tips">
<el-alert
title="上传提示"
type="info"
:closable="false"
show-icon
>
<ul>
<li>支持批量拖拽上传文件</li>
<li>单个文件大小不超过 10MB</li>
<li>支持的文件类型图片文档视频音频等</li>
<li>建议先选择分类再上传文件</li>
<li>上传大文件时请保持网络稳定</li>
</ul>
</el-alert>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, type UploadInstance, type UploadFile } from 'element-plus'
import { UploadFilled, Upload } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/file'
// Props & Emits
const emit = defineEmits(['upload-success'])
// 响应式数据
const uploadRef = ref<UploadInstance>()
const fileList = ref<UploadFile[]>([])
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadedCount = ref(0)
const successCount = ref(0)
const failedCount = ref(0)
const totalCount = ref(0)
const autoUpload = ref(false)
// 上传表单
const uploadForm = ref({
category: '文档',
})
// 计算属性
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL
return `${baseUrl}/api/files`
})
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token')
return {
Authorization: `Bearer ${token}`,
}
})
const uploadStatus = computed(() => {
if (uploadProgress.value === 100 && failedCount.value === 0) return 'success'
if (failedCount.value > 0 && uploadedCount.value === totalCount.value) return 'exception'
return undefined
})
const successRate = computed(() => {
if (totalCount.value === 0) return 0
return Math.round((successCount.value / totalCount.value) * 100)
})
// 方法
const beforeUpload = (file: File) => {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
ElMessage.error(`文件 "${file.name}" 大小不能超过 10MB!`)
return false
}
// 检查是否已存在同名文件
const exists = fileList.value.some((f) => f.name === file.name)
if (exists) {
ElMessage.warning(`文件 "${file.name}" 已存在`)
return false
}
return true
}
const handleFileChange = (file: UploadFile, files: UploadFile[]) => {
fileList.value = files
}
const handleFileRemove = (file: UploadFile, files: UploadFile[]) => {
fileList.value = files
}
const removeFile = (index: number) => {
fileList.value.splice(index, 1)
}
const clearFiles = () => {
fileList.value = []
uploadProgress.value = 0
uploadedCount.value = 0
successCount.value = 0
failedCount.value = 0
totalCount.value = 0
}
const submitUpload = async () => {
if (fileList.value.length === 0) {
ElMessage.warning('请先选择要上传的文件')
return
}
try {
uploading.value = true
uploadProgress.value = 0
uploadedCount.value = 0
successCount.value = 0
failedCount.value = 0
totalCount.value = fileList.value.length
// 逐个上传文件,调用uploadFile API
for (let i = 0; i < fileList.value.length; i++) {
const fileItem = fileList.value[i]
const file = fileItem.raw as File
if (!file) {
uploadedCount.value++
failedCount.value++
fileItem.status = 'fail'
continue
}
try {
fileItem.status = 'uploading'
// 创建FormData
const formData = new FormData()
formData.append('file', file)
formData.append('category', uploadForm.value.category)
// 调用uploadFile API
const response = await uploadFile(formData, {
category: uploadForm.value.category,
})
if (response.success) {
fileItem.status = 'success'
successCount.value++
ElMessage.success(`文件 "${file.name}" 上传成功`)
} else {
fileItem.status = 'fail'
failedCount.value++
ElMessage.error(response.message || `文件 "${file.name}" 上传失败`)
}
} catch (error: any) {
fileItem.status = 'fail'
failedCount.value++
console.error(`文件 "${file.name}" 上传失败:`, error)
ElMessage.error(`文件 "${file.name}" 上传失败: ${error.message || error}`)
}
uploadedCount.value++
uploadProgress.value = Math.round((uploadedCount.value / totalCount.value) * 100)
}
// 所有文件上传完成
uploading.value = false
if (successCount.value === totalCount.value) {
ElMessage.success('所有文件上传完成')
emit('upload-success')
setTimeout(() => {
clearFiles()
}, 2000)
} else if (successCount.value > 0) {
ElMessage.warning(
`上传完成,成功 ${successCount.value}/${totalCount.value} 个文件`
)
} else {
ElMessage.error('所有文件上传失败')
}
} catch (error) {
console.error('批量上传失败:', error)
ElMessage.error('批量上传失败')
uploading.value = false
}
}
const handleUploadProgress = (event: any, file: UploadFile, fileList: UploadFile[]) => {
// Element Plus upload组件的进度回调(用于自动上传模式)
if (autoUpload.value) {
// 自动上传模式下,进度由组件自己管理
}
}
const handleUploadSuccess = (response: any, file: UploadFile) => {
uploadedCount.value++
if (response.success) {
file.status = 'success'
successCount.value++
ElMessage.success(`文件 "${file.name}" 上传成功`)
} else {
file.status = 'fail'
failedCount.value++
ElMessage.error(response.message || `文件 "${file.name}" 上传失败`)
}
// 更新进度
uploadProgress.value = Math.round((uploadedCount.value / totalCount.value) * 100)
// 所有文件上传完成
if (uploadedCount.value === totalCount.value) {
uploading.value = false
if (successCount.value === totalCount.value) {
ElMessage.success('所有文件上传完成')
emit('upload-success')
// 延迟清空列表
setTimeout(() => {
clearFiles()
}, 2000)
} else if (successCount.value > 0) {
ElMessage.warning(
`上传完成,成功 ${successCount.value}/${totalCount.value} 个文件`
)
} else {
ElMessage.error('所有文件上传失败')
}
}
}
const handleUploadError = (error: any, file: UploadFile) => {
uploadedCount.value++
failedCount.value++
file.status = 'fail'
console.error('上传失败:', error)
ElMessage.error(`文件 "${file.name}" 上传失败`)
// 更新进度
uploadProgress.value = Math.round((uploadedCount.value / totalCount.value) * 100)
// 所有文件处理完成
if (uploadedCount.value === totalCount.value) {
uploading.value = false
}
}
const getStatusType = (status: string) => {
switch (status) {
case 'success':
return 'success'
case 'uploading':
return 'warning'
case 'fail':
return 'danger'
default:
return 'info'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'success':
return '成功'
case 'uploading':
return '上传中'
case 'fail':
return '失败'
default:
return '等待上传'
}
}
const getFileType = (fileName: string) => {
const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase()
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
const docExts = ['.doc', '.docx', '.pdf', '.txt', '.xls', '.xlsx']
const videoExts = ['.mp4', '.avi', '.mov', '.wmv']
const audioExts = ['.mp3', '.wav', '.flac']
if (imageExts.includes(ext)) return '图片'
if (docExts.includes(ext)) return '文档'
if (videoExts.includes(ext)) return '视频'
if (audioExts.includes(ext)) return '音频'
return '其他'
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.upload-container {
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
min-height: 500px;
}
.upload-area {
margin-bottom: 20px;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-settings {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.file-preview {
margin-bottom: 20px;
}
.upload-actions {
margin-top: 20px;
text-align: center;
padding: 20px 0;
border-top: 1px solid #eee;
}
.upload-progress {
margin-bottom: 20px;
}
.progress-info {
display: flex;
justify-content: space-around;
margin-top: 16px;
font-size: 14px;
color: #606266;
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
}
.progress-info span {
font-weight: 500;
}
.upload-tips {
margin-top: 20px;
}
.upload-tips ul {
margin: 10px 0 0 20px;
padding: 0;
list-style: disc;
}
.upload-tips li {
margin: 6px 0;
color: #606266;
font-size: 14px;
}
.tip-text {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
:deep(.el-upload__text em) {
color: #409eff;
font-style: normal;
font-weight: 600;
}
</style>
+92 -834
View File
@@ -1,872 +1,130 @@
<template>
<el-card class="box-card">
<div class="header-bar">
<h2>文件管理</h2>
</div>
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载程序数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<el-alert title="加载失败" :message="error" type="error" show-icon />
<el-button type="primary" @click="fetchPrograms">重试</el-button>
</div>
<div v-else class="files-module fancy-bg">
<!-- 顶部功能卡片区域 -->
<div class="function-cards">
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/list')"
>
<div class="card-content">
<div class="card-icon">
<i class="fa-solid fa-folder-open"></i>
</div>
<div class="card-info">
<span class="card-title">文件列表</span>
<span class="card-desc">查看和管理所有文件</span>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/upload')"
>
<div class="card-content">
<div class="card-icon upload">
<i class="fa-solid fa-cloud-arrow-up"></i>
</div>
<div class="card-info">
<span class="card-title">批量上传</span>
<span class="card-desc">批量上传文件至系统</span>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="8">
<el-card
class="function-card card-gradient"
shadow="hover"
@click="$router.push('/files/categories')"
>
<div class="card-content">
<div class="card-icon tags">
<i class="fa-solid fa-layer-group"></i>
</div>
<div class="card-info">
<span class="card-title">分类管理</span>
<span class="card-desc">管理分类/标签</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="files-management">
<template #header>
<div class="card-header">
<h2>
<el-icon><Folder /></el-icon>
文件管理系统
</h2>
</div>
</template>
<!-- 搜索和筛选 -->
<div class="search-filter stylish-panel">
<el-input
v-model="searchKeyword"
placeholder="搜索文件名、原始文件名或分类"
clearable
@input="handleSearch"
size="large"
class="input-search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="filterCategory"
placeholder="全部分类"
clearable
@change="handleFilter"
size="large"
class="select-category"
>
<el-option
v-for="category in categories"
:key="category"
:label="category"
:value="category"
/>
</el-select>
</div>
<!-- 文件数据列表 -->
<div class="file-list stylish-panel">
<el-table
:data="paginatedFiles"
v-loading="loading"
highlight-current-row
border
:default-sort="{ prop: 'upload_time', order: 'descending' }"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="file_name" label="文件名" min-width="200">
<template #default="{ row }">
<div class="file-name">
<el-icon class="icon-doc"><Document /></el-icon>
<span>{{ row.file_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column
prop="original_name"
label="原始文件名"
min-width="180"
/>
<el-table-column prop="category" label="分类" width="100">
<template #default="{ row }">
<el-tag effect="dark" type="success">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="大小" width="100">
<template #default="{ row }">
<el-tag effect="plain">{{
formatFileSize(row.file_size)
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_type" label="类型" width="90" />
<el-table-column prop="upload_by" label="上传人" width="120" />
<el-table-column
prop="upload_time"
label="上传时间"
width="170"
sortable
>
<template #default="{ row }">
<span style="color: #8ea1d6">{{
formatDate(row.upload_time)
}}</span>
</template>
</el-table-column>
<el-table-column
label="操作"
width="170"
fixed="right"
align="center"
>
<template #default="{ row }">
<el-tooltip content="查看详情">
<el-button
type="primary"
circle
plain
size="small"
@click="viewFile(row)"
>
<el-icon><View /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除">
<el-button
circle
type="danger"
plain
size="small"
@click="deleteFile(row)"
>
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页区域 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalFiles"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 上传文件对话框 -->
<el-dialog
v-model="showUploadDialog"
title="上传文件"
width="500px"
:before-close="handleUploadClose"
class="upload-dialog"
>
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ category: uploadForm.category }"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
:show-file-list="false"
multiple
>
<el-icon
class="el-icon--upload"
style="font-size: 44px; color: #36b2fa"
><upload-filled
/></el-icon>
<div class="el-upload__text">
将文件拖到此处<em style="color: #409eff">点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持单个或批量上传单文件大小不超过
<span style="color: #f56c6c; font-weight: 600">10MB</span>
</div>
</template>
</el-upload>
<div class="upload-form upform-padding">
<el-form :model="uploadForm" label-width="80px">
<el-form-item label="分类">
<el-select
v-model="uploadForm.category"
placeholder="请选择分类"
style="width: 100%"
>
<el-option label="文档" value="文档" />
<el-option label="图片" value="图片" />
<el-option label="视频" value="视频" />
<el-option label="音频" value="音频" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="是否公开">
<el-switch
v-model="uploadForm.isPublic"
active-color="#409EFF"
inactive-color="#bfbfbf"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showUploadDialog = false"> </el-button>
<el-button type="primary" @click="submitUpload">上传</el-button>
<!-- 标签页切换 -->
<el-tabs v-model="activeTab" class="file-tabs" @tab-change="handleTabChange">
<!-- 文件列表 -->
<el-tab-pane label="文件列表" name="list">
<template #label>
<span class="custom-tabs-label">
<el-icon><List /></el-icon>
<span>文件列表</span>
</span>
</template>
</el-dialog>
<FileList @refresh="handleRefresh" />
</el-tab-pane>
<!-- 文件详情对话框 -->
<el-dialog
v-model="showFileDetail"
:title="currentFile ? currentFile.original_name : '文件详情'"
width="650px"
class="file-detail-dialog"
>
<div v-if="currentFile" class="file-detail">
<el-descriptions :column="2" border size="large">
<el-descriptions-item label="文件ID">
<el-tag>{{ currentFile.id }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件名">
<span class="detail-primary">{{ currentFile.file_name }}</span>
</el-descriptions-item>
<el-descriptions-item label="原始文件名">{{
currentFile.original_name
}}</el-descriptions-item>
<el-descriptions-item label="文件大小">
<el-tag type="info">{{
formatFileSize(currentFile.file_size)
}}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件类型">{{
currentFile.file_type
}}</el-descriptions-item>
<el-descriptions-item label="文件扩展名">{{
currentFile.file_ext
}}</el-descriptions-item>
<el-descriptions-item label="分类">
<el-tag type="success" effect="plain">{{
currentFile.category
}}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上传人">
<el-tag>{{ currentFile.upload_by }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上传时间">{{
formatDate(currentFile.upload_time)
}}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag
:type="currentFile.status === 1 ? 'success' : 'danger'"
effect="dark"
>
{{ currentFile.status === 1 ? "正常" : "已删除" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否公开">
<el-tag
:type="currentFile.is_public === 1 ? 'success' : 'info'"
effect="plain"
>
{{ currentFile.is_public === 1 ? "公开" : "私有" }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="detail-btns">
<el-button type="primary" @click="downloadFile(currentFile)">
<el-icon><Download /></el-icon> 下载文件
</el-button>
<el-button @click="copyFileUrl(currentFile)">
<el-icon><Link /></el-icon> 复制链接
</el-button>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showFileDetail = false">关闭</el-button>
<!-- 批量上传 -->
<el-tab-pane label="批量上传" name="upload">
<template #label>
<span class="custom-tabs-label">
<el-icon><Upload /></el-icon>
<span>批量上传</span>
</span>
</template>
</el-dialog>
</div>
<FileUpload @upload-success="handleUploadSuccess" />
</el-tab-pane>
<!-- 分类管理 -->
<el-tab-pane label="分类管理" name="category">
<template #label>
<span class="custom-tabs-label">
<el-icon><Grid /></el-icon>
<span>分类管理</span>
</span>
</template>
<FileCategory @category-change="handleCategoryChange" />
</el-tab-pane>
</el-tabs>
</el-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Download,
Delete,
Search,
Document,
View,
Link,
UploadFilled,
} from "@element-plus/icons-vue";
import { getFilesByTenant, getFileById, deleteFile as deleteFileApi, searchFiles } from "@/api/file";
import { useAuthStore } from "@/stores/auth";
import { ref } from 'vue'
import { Folder, List, Upload, Grid } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import FileList from './FileList.vue'
import FileUpload from './FileUpload.vue'
import FileCategory from './FileCategory.vue'
const router = useRouter();
const authStore = useAuthStore();
// 当前激活的标签页
const activeTab = ref<string>('list')
// 文件列表数据
const allFiles = ref<any[]>([]);
const categories = ref<string[]>([]);
const filteredFiles = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const searchKeyword = ref("");
const filterCategory = ref("");
const pageSize = ref(10);
const currentPage = ref(1);
const totalFiles = ref(0);
const showUploadDialog = ref(false);
// 获取当前租户ID
const getCurrentTenantId = () => {
if (authStore.user && authStore.user.tenant_id) {
return authStore.user.tenant_id;
}
const userInfo = localStorage.getItem('userInfo');
if (userInfo) {
try {
const user = JSON.parse(userInfo);
return user.tenant_id || user.tenantId || 0;
} catch (e) {
console.error('Failed to parse user info:', e);
}
}
return 0;
};
// 获取文件列表
async function fetchFiles() {
loading.value = true;
error.value = "";
try {
const tenantId = getCurrentTenantId();
const res = await getFilesByTenant(tenantId);
if (res.success && res.data) {
const files = Array.isArray(res.data) ? res.data : [];
allFiles.value = files.map((file: any) => ({
...file,
upload_time: file.upload_time || file.create_time,
}));
// 提取所有分类
const uniqueCategories = new Set<string>();
files.forEach((file: any) => {
if (file.category) {
uniqueCategories.add(file.category);
}
});
categories.value = Array.from(uniqueCategories).sort();
applyFilters();
totalFiles.value = filteredFiles.value.length;
} else {
error.value = res.message || "获取文件列表失败";
allFiles.value = [];
totalFiles.value = 0;
}
} catch (err: any) {
error.value = err.message || "获取文件列表失败";
allFiles.value = [];
totalFiles.value = 0;
} finally {
loading.value = false;
}
// 标签页切换处理
const handleTabChange = (tabName: string | number) => {
console.log('切换到标签页:', tabName)
}
// 应用搜索和筛选
function applyFilters() {
let result = [...allFiles.value];
// 分类筛选
if (filterCategory.value) {
result = result.filter((file) => file.category === filterCategory.value);
}
// 关键词搜索
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase();
result = result.filter(
(file) =>
(file.file_name && file.file_name.toLowerCase().includes(keyword)) ||
(file.original_name && file.original_name.toLowerCase().includes(keyword)) ||
(file.category && file.category.toLowerCase().includes(keyword))
);
}
filteredFiles.value = result;
totalFiles.value = result.length;
// 重置到第一页
if (currentPage.value > 1 && result.length === 0) {
currentPage.value = 1;
}
// 刷新文件列表
const handleRefresh = () => {
ElMessage.success('文件列表已刷新')
}
// 格式化文件大小
const formatFileSize = (bytes: number | string | undefined | null): string => {
if (!bytes && bytes !== 0) return "0 B";
const bytesValue = typeof bytes === 'string' ? parseFloat(bytes) : Number(bytes);
if (isNaN(bytesValue) || bytesValue < 0) return "0 B";
if (bytesValue === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytesValue) / Math.log(k));
return parseFloat((bytesValue / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 上传成功后的处理
const handleUploadSuccess = () => {
// 切换回文件列表标签页并刷新
activeTab.value = 'list'
ElMessage.success('文件上传成功,已返回文件列表')
}
// 格式化日期
const formatDate = (dateStr: string | number | undefined | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return String(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
// 上传配置
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ;
return `${baseUrl}/api/files`;
});
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token');
return {
'Authorization': `Bearer ${token}`
};
});
const uploadForm = ref({
category: "",
isPublic: false,
});
const showFileDetail = ref(false);
const currentFile = ref<any>(null);
// 搜索处理
const handleSearch = () => {
applyFilters();
};
// 筛选处理
const handleFilter = () => {
fetchFiles();
};
// 分页大小变化
const handleSizeChange = (val: number) => {
pageSize.value = val;
currentPage.value = 1;
};
// 当前页变化
const handleCurrentChange = (val: number) => {
currentPage.value = val;
};
// 上传对话框关闭
const handleUploadClose = () => {
showUploadDialog.value = false;
uploadForm.value.category = "";
uploadForm.value.isPublic = false;
};
// 上传成功
const handleUploadSuccess = (response: any, file: any) => {
if (response.success) {
ElMessage.success('文件上传成功!');
showUploadDialog.value = false;
uploadForm.value.category = "";
uploadForm.value.isPublic = false;
fetchFiles();
} else {
ElMessage.error(response.message || '文件上传失败!');
}
};
// 上传失败
const handleUploadError = (error: Error, file: any) => {
ElMessage.error('文件上传失败:' + (error.message || error));
};
// 上传前校验
const beforeUpload = (file: File) => {
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB');
return false;
}
return true;
};
// 提交上传
const submitUpload = () => {
// Element Plus 的 el-upload 组件会自动处理上传
// 不需要额外处理
};
// 查看文件详情
const viewFile = async (row: any) => {
try {
loading.value = true;
const res = await getFileById(row.id);
if (res.success && res.data) {
currentFile.value = res.data;
showFileDetail.value = true;
} else {
ElMessage.error(res.message || "获取文件详情失败");
}
} catch (err: any) {
ElMessage.error(err.message || "获取文件详情失败");
} finally {
loading.value = false;
}
};
// 复制文件链接
const copyFileUrl = (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const fileUrl = `${baseUrl}/api/files/${file.id}/download`;
if (navigator.clipboard) {
navigator.clipboard.writeText(fileUrl).then(() => {
ElMessage.success('文件链接已复制到剪贴板');
}).catch(() => {
ElMessage.error('复制失败,请手动复制');
});
} else {
// 降级方案
const textarea = document.createElement('textarea');
textarea.value = fileUrl;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
ElMessage.success('文件链接已复制到剪贴板');
} catch {
ElMessage.error('复制失败,请手动复制');
}
document.body.removeChild(textarea);
}
};
// 下载文件
const downloadFile = (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const downloadUrl = `${baseUrl}/api/files/${file.id}/download`;
const token = localStorage.getItem('token');
const link = document.createElement('a');
link.href = downloadUrl;
if (token) {
// 如果需要带token下载,可以使用fetch然后创建blob
fetch(downloadUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
link.href = url;
link.download = file.original_name || file.file_name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('文件下载开始');
})
.catch(err => {
ElMessage.error('文件下载失败:' + err.message);
});
} else {
link.download = file.original_name || file.file_name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
// 删除文件
const deleteFile = async (file: any) => {
try {
await ElMessageBox.confirm(
`确定要删除文件「${file.original_name || file.file_name}」吗?删除后不可恢复。`,
'警告',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
}
);
loading.value = true;
const res = await deleteFileApi(file.id);
if (res.success) {
ElMessage.success('删除成功');
await fetchFiles();
} else {
ElMessage.error(res.message || '删除失败');
}
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.message || '删除失败');
}
} finally {
loading.value = false;
}
};
// 分页计算
const paginatedFiles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredFiles.value.slice(start, end);
});
// 更新表格数据源为分页后的数据
watch([filteredFiles, currentPage, pageSize], () => {
// filteredFiles 已经在 computed 中处理,但表格需要直接使用 paginatedFiles
}, { immediate: true });
onMounted(() => {
fetchFiles();
});
// 分类变更处理
const handleCategoryChange = () => {
ElMessage.success('分类已更新')
}
</script>
<style scoped>
.fancy-bg {
padding: 28px 14px 24px 14px;
.files-management {
min-height: calc(100vh - 120px);
}
.stylish-panel {
background: #fff;
border-radius: 14px;
box-shadow: 0 4px 14px 0 #e2eafe77;
margin-bottom: 28px;
padding: 28px 18px 24px 18px;
}
.function-cards {
margin-bottom: 32px;
}
.function-card {
border: none !important;
border-radius: 14px !important;
box-shadow: 0 8px 24px 0 #bedcff11 !important;
background: #fff;
cursor: pointer;
transition:
transform 0.15s cubic-bezier(0.17, 0.67, 0.83, 0.67),
box-shadow 0.18s;
}
.function-card:hover {
transform: translateY(-4px) scale(1.032);
box-shadow: 0 18px 48px 0 #6bbaf522 !important;
}
.card-gradient {
background: linear-gradient(120deg, #ecf4ff 94%, #eff8ff 100%) !important;
}
.card-content {
.card-header {
display: flex;
align-items: center;
padding: 26px 16px;
min-height: 95px;
justify-content: space-between;
}
.card-icon {
font-size: 34px;
width: 54px;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
color: #3178ea;
background: linear-gradient(133deg, #d9eaff 82%, #f2f6fd 100%);
border-radius: 12px;
margin-right: 18px;
transition: color 0.25s;
}
.card-icon.upload {
color: #13c2c2;
background: linear-gradient(145deg, #c5faea 58%, #d6f3ff 98%);
}
.card-icon.tags {
color: #a048ff;
background: linear-gradient(143deg, #e8dafe 88%, #f3e2fd 100%);
}
.card-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.card-title {
font-size: 19px;
font-weight: 700;
color: #2c3547;
letter-spacing: 1.5px;
margin-bottom: 3px;
}
.card-desc {
font-size: 13px;
color: #6c7ea5;
font-weight: 400;
}
.rc-title-bar {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.recent-icon {
.card-header h2 {
margin: 0;
font-size: 20px;
color: #409eff;
margin-right: 8px;
}
.recent-title {
font-size: 17px;
font-weight: 600;
color: #293a46;
}
.recent-table {
border-radius: 6px !important;
}
.input-search {
width: 300px !important;
margin-right: 18px !important;
box-shadow: 0 2px 6px 0 #dae7fd24;
}
.select-category {
width: 170px !important;
margin-right: 18px;
}
.switch-mine {
margin-left: 8px;
}
.file-list {
margin-bottom: 28px;
background: #fff;
}
.file-name {
color: #303133;
display: flex;
align-items: center;
gap: 8px;
}
.icon-doc {
margin-right: 8px;
color: #2090ff;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
background: none;
}
.upload-dialog .el-dialog__body {
padding-top: 20px;
}
.upform-padding {
.file-tabs {
margin-top: 20px;
border-top: 1px solid #eee;
padding-top: 20px;
}
.file-detail-dialog .el-dialog__body {
padding-top: 18px;
background: #fafcff;
.custom-tabs-label {
display: flex;
align-items: center;
gap: 6px;
}
.file-detail {
max-height: 380px;
overflow-y: auto;
:deep(.el-tabs__header) {
margin-bottom: 20px;
}
.detail-primary {
color: #416cf7;
:deep(.el-tabs__item) {
font-size: 15px;
padding: 0 20px;
height: 45px;
line-height: 45px;
}
:deep(.el-tabs__item.is-active) {
color: #409eff;
font-weight: 600;
}
.detail-btns {
margin-top: 28px;
display: flex;
justify-content: center;
gap: 22px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
}
@media (max-width: 900px) {
.stylish-panel,
.file-list,
.function-cards {
padding: 10px !important;
}
.fancy-bg {
padding: 10px !important;
}
}
</style>
+2 -2
View File
@@ -145,8 +145,8 @@
<el-radio-button :value="3">权限按钮</el-radio-button>
</el-radio-group>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
<div> 页面菜单有路由和组件地址可访问完整功能页面</div>
<div> 目录菜单只有路由地址用于组织结构无实际页面</div>
<div> 页面菜单有路由和组件地址用于<span style="color: var(--el-color-primary);">页面管理</span></div>
<div> 目录菜单只有路由地址用于<span style="color: var(--el-color-primary);">接口管理</span><span style="color: var(--el-color-primary);">目录管理</span></div>
<div> 权限按钮无路由和组件仅用于权限控制</div>
</div>
</el-form-item>
+44 -38
View File
@@ -128,7 +128,7 @@
</el-form-item>
<el-form-item label="角色" v-if="dialogTitle !== '修改密码'">
<el-select
v-model="form.roleId"
v-model="form.role"
placeholder="请选择角色"
style="width: 100%"
:loading="loadingRoles"
@@ -167,6 +167,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh } from "@element-plus/icons-vue";
import {
getAllUsers,
getTenantUsers,
addUser,
editUser,
deleteUser,
@@ -269,8 +270,10 @@ const validateConfirmPassword = (password: string, confirmPassword: string) => {
const fetchUsers = async () => {
loading.value = true;
// 传tenantid给接口
let tenantId = getCurrentTenantId ? getCurrentTenantId() : null;
try {
const res = await getAllUsers();
const res = await getTenantUsers(tenantId);
// 兼容接口返回的数据结构
let userList: any[] = [];
if (Array.isArray(res)) {
@@ -286,14 +289,12 @@ const fetchUsers = async () => {
users.value = userList.map((item: any) => {
// 查找角色名称
let roleName = '';
let roleId = item.role_id || item.roleId || null;
if (roleId) {
const role = roleList.value.find(r => r.roleId === roleId);
roleName = role ? role.roleName : '';
} else if (item.role) {
// 兼容旧的role字段,尝试从roleList中查找
const role = roleList.value.find(r => r.roleCode === item.role);
roleName = role ? role.roleName : (item.role === 'admin' ? '管理员' : '普通用户');
let roleValue = item.role || null; // role 字段存储的是角色ID(数字)
if (roleValue) {
// 通过角色ID查找角色信息
const roleInfo = roleList.value.find(r => r.roleId === roleValue);
roleName = roleInfo ? roleInfo.roleName : '';
}
return {
@@ -301,8 +302,7 @@ const fetchUsers = async () => {
username: item.username,
nickname: item.nickname,
email: item.email,
role: item.role || '',
roleId: roleId,
role: roleValue, // role 字段存储角色ID
roleName: roleName,
status: item.status || "active",
lastLoginTime: item.lastLoginTime
@@ -343,13 +343,12 @@ const dialogVisible = ref(false);
const dialogTitle = ref("");
const isEdit = ref(false);
const form = ref<any>({
id: 0,
id: null,
username: "",
nickname: "",
password: "",
email: "",
roleId: null,
role: "",
role: null, // role 字段存储角色ID
status: "active",
tenant_id: null,
});
@@ -397,13 +396,12 @@ const handleAddUser = () => {
}
form.value = {
id: 0,
id: 0, // 添加用户时ID为0
username: "",
nickname: "",
password: "",
email: "",
roleId: null,
role: "",
role: null, // role 字段存储角色ID
status: "active",
tenant_id: tenantId,
};
@@ -420,12 +418,15 @@ const handleEdit = async (user: User) => {
// 获取当前租户ID
const tenantId = getCurrentTenantId();
// 处理角色ID,兼容旧数据
let roleId = data.role_id || data.roleId || null;
if (!roleId && data.role) {
// 如果只有role代码,尝试从roleList中查找对应的roleId
const role = roleList.value.find(r => r.roleCode === data.role);
roleId = role ? role.roleId : null;
// 处理角色data.role 存储的是角色ID(数字)
let roleValue = data.role || null;
// 处理状态:后端返回的是数字(0或1),前端使用字符串("active"或"inactive"
let statusStr = "active";
if (typeof data.status === 'number') {
statusStr = data.status === 1 ? "active" : "inactive";
} else if (typeof data.status === 'string') {
statusStr = data.status;
}
form.value = {
@@ -434,9 +435,8 @@ const handleEdit = async (user: User) => {
nickname: data.nickname,
password: "",
email: data.email,
roleId: roleId,
role: data.role || "",
status: data.status || "active",
role: roleValue, // role 字段存储角色ID
status: statusStr,
tenant_id: data.tenant_id || tenantId,
};
} catch (e) {
@@ -502,25 +502,31 @@ const submitForm = async () => {
passwordError.value = res.message || "密码修改失败";
}
} else if (isEdit.value) {
// 构建提交数据,优先使用roleId
// 检查ID是否存在
if (!form.value.id || form.value.id === 0) {
ElMessage.error("用户ID不能为空,请重新选择用户");
return;
}
// 构建提交数据
const submitData: any = {
id: form.value.id,
username: form.value.username,
nickname: form.value.nickname,
email: form.value.email,
status: form.value.status,
};
if (form.value.roleId) {
submitData.roleId = form.value.roleId;
} else if (form.value.role) {
// 兼容旧数据,如果没有roleId但有role代码,也传递
submitData.role = form.value.role;
// role 字段存储角色ID
if (form.value.role) {
submitData.role = form.value.role; // 后端接收 role 参数
}
if (form.value.tenant_id) {
submitData.tenant_id = form.value.tenant_id;
}
console.log('编辑用户提交数据:', submitData); // 调试日志
await editUser(form.value.id, submitData);
ElMessage.success({
message: "更新成功",
@@ -533,7 +539,7 @@ const submitForm = async () => {
dialogVisible.value = false;
fetchUsers();
} else {
// 构建提交数据,优先使用roleId
// 构建提交数据
const submitData: any = {
username: form.value.username,
nickname: form.value.nickname,
@@ -542,16 +548,16 @@ const submitForm = async () => {
status: form.value.status,
};
if (form.value.roleId) {
submitData.roleId = form.value.roleId;
} else if (form.value.role) {
submitData.role = form.value.role;
// role 字段存储角色ID
if (form.value.role) {
submitData.role = form.value.role; // 后端接收 role 参数
}
if (form.value.tenant_id) {
submitData.tenant_id = form.value.tenant_id;
}
console.log('添加用户提交数据:', submitData); // 调试日志
await addUser(submitData);
ElMessage.success({
message: "添加成功",