初始化
This commit is contained in:
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>文件管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1989
web/package-lock.json
generated
Normal file
1989
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
web/package.json
Normal file
24
web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "seeyon-filesystem-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.5",
|
||||
"element-plus": "^2.5.5",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
24
web/src/App.vue
Normal file
24
web/src/App.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 应用根组件
|
||||
* 仅包含路由视图, 布局由各页面自行管理
|
||||
*/
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
110
web/src/api/auth.ts
Normal file
110
web/src/api/auth.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
* 包含: 登录/注册/SSO/权限
|
||||
*/
|
||||
import request from './request'
|
||||
|
||||
/** 登录请求参数 */
|
||||
interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 登录响应数据 */
|
||||
interface LoginResult {
|
||||
token: string
|
||||
username: string
|
||||
nickname: string
|
||||
role: string
|
||||
}
|
||||
|
||||
/** 注册请求参数 */
|
||||
interface RegisterParams {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
/** SSO提供商信息 */
|
||||
export interface SSOProvider {
|
||||
id: number
|
||||
name: string
|
||||
display_name: string
|
||||
icon: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/** 用户权限信息 */
|
||||
export interface UserPermissionVO {
|
||||
roles: { id: number; name: string; display_name: string }[]
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
// ========== 账密登录 ==========
|
||||
|
||||
export function login(params: LoginParams) {
|
||||
return request.post<any, { data: LoginResult }>('/auth/login', params)
|
||||
}
|
||||
|
||||
export function register(params: RegisterParams) {
|
||||
return request.post('/auth/register', params)
|
||||
}
|
||||
|
||||
export function getProfile() {
|
||||
return request.get('/auth/profile')
|
||||
}
|
||||
|
||||
// ========== SSO 单点登录 ==========
|
||||
|
||||
/** 获取启用的SSO提供商列表(登录页展示) */
|
||||
export function getSSOProviders() {
|
||||
return request.get<any, { data: SSOProvider[] }>('/sso/providers')
|
||||
}
|
||||
|
||||
/** 获取SSO授权跳转URL */
|
||||
export function getSSOAuthURL(providerId: number) {
|
||||
return request.get<any, { data: { auth_url: string; state: string } }>('/sso/auth/url', {
|
||||
params: { provider_id: providerId },
|
||||
})
|
||||
}
|
||||
|
||||
/** SSO回调(前端处理授权码) */
|
||||
export function ssoCallback(providerId: number, code: string) {
|
||||
return request.post<any, { data: LoginResult }>('/sso/callback', {
|
||||
provider_id: providerId,
|
||||
code,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 权限管理 ==========
|
||||
|
||||
/** 获取当前用户的权限信息 */
|
||||
export function getUserPermissions() {
|
||||
return request.get<any, { data: UserPermissionVO }>('/auth/permissions')
|
||||
}
|
||||
|
||||
/** 管理员: 获取所有角色 */
|
||||
export function getRoles() {
|
||||
return request.get('/admin/roles')
|
||||
}
|
||||
|
||||
/** 管理员: 创建角色 */
|
||||
export function createRole(data: { name: string; display_name: string; description?: string }) {
|
||||
return request.post('/admin/roles', data)
|
||||
}
|
||||
|
||||
/** 管理员: 设置角色权限 */
|
||||
export function setRolePermissions(roleId: number, permissionIds: number[]) {
|
||||
return request.put(`/admin/roles/${roleId}/permissions`, { permission_ids: permissionIds })
|
||||
}
|
||||
|
||||
/** 管理员: 获取所有权限 */
|
||||
export function getPermissions() {
|
||||
return request.get('/admin/permissions')
|
||||
}
|
||||
|
||||
/** 管理员: 设置用户角色 */
|
||||
export function setUserRoles(userId: number, roleIds: number[]) {
|
||||
return request.put(`/admin/users/${userId}/roles`, { role_ids: roleIds })
|
||||
}
|
||||
185
web/src/api/file.ts
Normal file
185
web/src/api/file.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 文件管理相关 API
|
||||
*/
|
||||
import request from './request'
|
||||
|
||||
/** 文件信息 */
|
||||
export interface FileInfo {
|
||||
id: number
|
||||
name: string
|
||||
extension: string
|
||||
folder_id: number
|
||||
size: number
|
||||
mime_type: string
|
||||
is_favorite: number
|
||||
download_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 文件夹信息 */
|
||||
export interface FolderInfo {
|
||||
id: number
|
||||
name: string
|
||||
parent_id: number
|
||||
path: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 文件列表响应 */
|
||||
export interface FileListResult {
|
||||
folders: FolderInfo[]
|
||||
files: FileInfo[]
|
||||
}
|
||||
|
||||
/** 分享信息 */
|
||||
export interface ShareInfo {
|
||||
id: number
|
||||
share_code: string
|
||||
share_url: string
|
||||
has_password: boolean
|
||||
expire_at: string | null
|
||||
max_download: number
|
||||
download_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param file 文件对象
|
||||
* @param folderId 目标文件夹ID, 0=根目录
|
||||
*/
|
||||
export function uploadFile(file: File, folderId: number = 0) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post<any, { data: FileInfo }>(`/file/upload?folder_id=${folderId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件夹内容(文件+子文件夹)
|
||||
*/
|
||||
export function getFolderChildren(folderId: number | string) {
|
||||
return request.get<any, { data: FileListResult }>(`/folder/${folderId}/children`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件夹
|
||||
*/
|
||||
export function createFolder(name: string, parentId: number = 0) {
|
||||
return request.post('/folder', { name, parent_id: parentId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件
|
||||
*/
|
||||
export function renameFile(fileId: number, newName: string) {
|
||||
return request.put(`/file/${fileId}/rename`, { new_name: newName })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件夹
|
||||
*/
|
||||
export function renameFolder(folderId: number, newName: string) {
|
||||
return request.put(`/folder/${folderId}/rename`, { new_name: newName })
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件(移入回收站)
|
||||
*/
|
||||
export function deleteFile(fileId: number) {
|
||||
return request.delete(`/file/${fileId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件夹(移入回收站)
|
||||
*/
|
||||
export function deleteFolder(folderId: number) {
|
||||
return request.delete(`/folder/${folderId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动文件
|
||||
*/
|
||||
export function moveFile(fileId: number, targetFolderId: number) {
|
||||
return request.post(`/file/${fileId}/move`, { target_folder_id: targetFolderId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文件
|
||||
*/
|
||||
export function searchFiles(keyword: string) {
|
||||
return request.get<any, { data: FileInfo[] }>('/file/search', { params: { keyword } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取回收站列表
|
||||
*/
|
||||
export function getTrash() {
|
||||
return request.get<any, { data: FileInfo[] }>('/file/trash')
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复文件
|
||||
*/
|
||||
export function restoreFile(fileId: number) {
|
||||
return request.post(`/file/${fileId}/restore`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 永久删除文件
|
||||
*/
|
||||
export function permanentDeleteFile(fileId: number) {
|
||||
return request.delete(`/file/${fileId}/permanent`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载链接
|
||||
*/
|
||||
export function getDownloadUrl(fileId: number): string {
|
||||
return `/api/file/${fileId}/download`
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分享
|
||||
*/
|
||||
export function createShare(fileId: number, password?: string, expireHours?: number, maxDownload?: number, allowPreview?: number, allowDownload?: number) {
|
||||
return request.post<any, { data: ShareInfo }>('/share', {
|
||||
file_id: fileId,
|
||||
password,
|
||||
expire_hours: expireHours,
|
||||
max_download: maxDownload,
|
||||
allow_preview: allowPreview,
|
||||
allow_download: allowDownload,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的分享列表
|
||||
*/
|
||||
export function getMyShares() {
|
||||
return request.get<any, { data: ShareInfo[] }>('/share')
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消分享
|
||||
*/
|
||||
export function cancelShare(shareId: number) {
|
||||
return request.delete(`/share/${shareId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享信息(公开)
|
||||
*/
|
||||
export function getShareInfo(code: string) {
|
||||
return request.get(`/s/${code}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分享密码
|
||||
*/
|
||||
export function verifySharePassword(code: string, password: string) {
|
||||
return request.post(`/s/${code}/verify`, { password })
|
||||
}
|
||||
83
web/src/api/request.ts
Normal file
83
web/src/api/request.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Axios 请求封装
|
||||
* 统一处理请求/响应拦截、Token注入、错误提示
|
||||
*/
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '../router'
|
||||
|
||||
// 创建 axios 实例
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
/**
|
||||
* 请求拦截器
|
||||
* 自动注入 JWT Token 到请求头
|
||||
*/
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 响应拦截器
|
||||
* 统一处理业务错误码, 401自动跳转登录
|
||||
*/
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const data = response.data
|
||||
|
||||
// 业务错误
|
||||
if (data.code !== 0) {
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 401: {
|
||||
// 只有之前有Token(已登录过)才提示"已过期"
|
||||
const hadToken = !!localStorage.getItem('token')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('nickname')
|
||||
localStorage.removeItem('role')
|
||||
if (hadToken) {
|
||||
ElMessage.error('登录已过期, 请重新登录')
|
||||
}
|
||||
router.push('/login')
|
||||
break
|
||||
}
|
||||
case 403:
|
||||
ElMessage.error('没有权限执行此操作')
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error(data?.message || '资源不存在')
|
||||
break
|
||||
default:
|
||||
ElMessage.error(data?.message || `请求失败 (${status})`)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('网络错误, 请检查连接')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
23
web/src/main.ts
Normal file
23
web/src/main.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Vue3 应用入口
|
||||
* 初始化 Vue、路由、状态管理、UI组件库
|
||||
*/
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import './assets/styles/global.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia()) // 状态管理
|
||||
app.use(router) // 路由
|
||||
app.use(ElementPlus, { // UI组件库(中文)
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
66
web/src/router/index.ts
Normal file
66
web/src/router/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 路由配置
|
||||
* 前台: 普通用户文件管理
|
||||
* 后台: 管理员系统管理
|
||||
*/
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// ====== 前台(用户) ======
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'FileManager',
|
||||
component: () => import('../views/FileManager.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/s/:code',
|
||||
name: 'ShareView',
|
||||
component: () => import('../views/ShareView.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
|
||||
// ====== 后台(管理员) ======
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'AdminLayout',
|
||||
component: () => import('../views/admin/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
redirect: '/admin/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'AdminDashboard', component: () => import('../views/admin/Dashboard.vue') },
|
||||
{ path: 'users', name: 'AdminUsers', component: () => import('../views/admin/UserManage.vue') },
|
||||
{ path: 'roles', name: 'AdminRoles', component: () => import('../views/admin/RoleManage.vue') },
|
||||
{ path: 'org', name: 'AdminOrg', component: () => import('../views/admin/OrganizationManage.vue') },
|
||||
{ path: 'storage', name: 'AdminStorage', component: () => import('../views/admin/StoragePolicyManage.vue') },
|
||||
{ path: 'storage-assign', name: 'AdminStorageAssign', component: () => import('../views/admin/StorageAssignManage.vue') },
|
||||
{ path: 'open-platform', name: 'AdminOpenPlatform', component: () => import('../views/admin/OpenPlatform.vue') },
|
||||
{ path: 'logs', name: 'AdminLogs', component: () => import('../views/admin/LogMonitor.vue') },
|
||||
{ path: 'blacklist', name: 'AdminBlacklist', component: () => import('../views/admin/IPBlacklist.vue') },
|
||||
{ path: 'backup', name: 'AdminBackup', component: () => import('../views/admin/BackupManage.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
93
web/src/stores/file.ts
Normal file
93
web/src/stores/file.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 文件管理状态管理
|
||||
* 管理当前目录路径、文件列表、选中状态
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getFolderChildren, type FileListResult, type FileInfo, type FolderInfo } from '../api/file'
|
||||
|
||||
/** 面包屑路径项 */
|
||||
export interface PathItem {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export const useFileStore = defineStore('file', () => {
|
||||
// 当前文件夹ID (0=根目录)
|
||||
const currentFolderId = ref(0)
|
||||
|
||||
// 面包屑路径
|
||||
const breadcrumbs = ref<PathItem[]>([{ id: 0, name: '根目录' }])
|
||||
|
||||
// 文件列表数据
|
||||
const folders = ref<FolderInfo[]>([])
|
||||
const files = ref<FileInfo[]>([])
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* 加载指定文件夹的内容
|
||||
*/
|
||||
async function loadFolder(folderId: number = 0) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getFolderChildren(folderId === 0 ? 'root' : folderId)
|
||||
const data: FileListResult = res.data
|
||||
|
||||
currentFolderId.value = folderId
|
||||
folders.value = data.folders || []
|
||||
files.value = data.files || []
|
||||
} catch (error) {
|
||||
console.error('加载文件列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入子文件夹
|
||||
* 更新面包屑路径
|
||||
*/
|
||||
function enterFolder(folderId: number, folderName: string) {
|
||||
breadcrumbs.value.push({ id: folderId, name: folderName })
|
||||
loadFolder(folderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到面包屑指定位置
|
||||
*/
|
||||
function navigateTo(index: number) {
|
||||
const item = breadcrumbs.value[index]
|
||||
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
|
||||
loadFolder(item.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回根目录
|
||||
*/
|
||||
function goRoot() {
|
||||
breadcrumbs.value = [{ id: 0, name: '根目录' }]
|
||||
loadFolder(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新当前目录
|
||||
*/
|
||||
function refresh() {
|
||||
loadFolder(currentFolderId.value)
|
||||
}
|
||||
|
||||
return {
|
||||
currentFolderId,
|
||||
breadcrumbs,
|
||||
folders,
|
||||
files,
|
||||
loading,
|
||||
loadFolder,
|
||||
enterFolder,
|
||||
navigateTo,
|
||||
goRoot,
|
||||
refresh,
|
||||
}
|
||||
})
|
||||
126
web/src/stores/user.ts
Normal file
126
web/src/stores/user.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 用户状态管理
|
||||
* 管理登录状态、用户信息、Token、权限
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi, getUserPermissions, type UserPermissionVO, type LoginParams } from '../api/auth'
|
||||
import router from '../router'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
const nickname = ref(localStorage.getItem('nickname') || '')
|
||||
const role = ref(localStorage.getItem('role') || '')
|
||||
|
||||
// RBAC权限
|
||||
const roles = ref<{ id: number; name: string; display_name: string }[]>([])
|
||||
const permissions = ref<string[]>([])
|
||||
|
||||
/** 是否已登录 */
|
||||
const isLoggedIn = () => !!token.value
|
||||
|
||||
/** 是否管理员(兼容旧逻辑) */
|
||||
const isAdmin = () => role.value === 'admin' || roles.value.some(r => r.name === 'admin')
|
||||
|
||||
/**
|
||||
* 检查是否拥有指定权限
|
||||
* 管理员拥有所有权限
|
||||
*/
|
||||
function hasPermission(code: string): boolean {
|
||||
if (isAdmin()) return true
|
||||
return permissions.value.includes(code) || permissions.value.includes('*')
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
async function login(params: LoginParams) {
|
||||
const res = await loginApi(params)
|
||||
const data = res.data
|
||||
saveLoginResult(data)
|
||||
// 首次登录需改密码
|
||||
if ((data as any).must_change_pwd) {
|
||||
router.push('/?change_pwd=1')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO登录(保存Token)
|
||||
*/
|
||||
function ssoLogin(data: { token: string; username: string; nickname: string; role: string }) {
|
||||
saveLoginResult(data)
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存登录结果到本地
|
||||
*/
|
||||
function saveLoginResult(data: { token: string; username: string; nickname: string; role: string }) {
|
||||
token.value = data.token
|
||||
username.value = data.username
|
||||
nickname.value = data.nickname
|
||||
role.value = data.role
|
||||
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('username', data.username)
|
||||
localStorage.setItem('nickname', data.nickname)
|
||||
localStorage.setItem('role', data.role)
|
||||
|
||||
// 登录后加载权限
|
||||
loadPermissions()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载用户权限
|
||||
*/
|
||||
async function loadPermissions() {
|
||||
if (!token.value) return
|
||||
try {
|
||||
const res = await getUserPermissions()
|
||||
const data: UserPermissionVO = res.data
|
||||
roles.value = data.roles || []
|
||||
permissions.value = data.permissions || []
|
||||
} catch (error) {
|
||||
console.error('加载权限失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
function logout() {
|
||||
token.value = ''
|
||||
username.value = ''
|
||||
nickname.value = ''
|
||||
role.value = ''
|
||||
roles.value = []
|
||||
permissions.value = []
|
||||
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('nickname')
|
||||
localStorage.removeItem('role')
|
||||
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
username,
|
||||
nickname,
|
||||
role,
|
||||
roles,
|
||||
permissions,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
hasPermission,
|
||||
login,
|
||||
ssoLogin,
|
||||
loadPermissions,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
628
web/src/views/FileManager.vue
Normal file
628
web/src/views/FileManager.vue
Normal file
@@ -0,0 +1,628 @@
|
||||
<template>
|
||||
<div class="file-manager">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<el-icon :size="24"><Files /></el-icon>
|
||||
<span>文件管理系统</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-item active" @click="fileStore.goRoot()">
|
||||
<el-icon><HomeFilled /></el-icon><span>全部文件</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="showUploadDialog = true">
|
||||
<el-icon><Upload /></el-icon><span>上传文件</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="handleCommand('shares')">
|
||||
<el-icon><Share /></el-icon><span>我的分享</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="handleCommand('trash')">
|
||||
<el-icon><Delete /></el-icon><span>回收站</span>
|
||||
</div>
|
||||
|
||||
<template v-if="userStore.isAdmin()">
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-item" @click="$router.push('/admin')">
|
||||
<el-icon><Setting /></el-icon><span>管理后台</span>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- 存储用量 -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="storage-info">
|
||||
<div class="storage-label">
|
||||
存储空间
|
||||
<span v-if="storageInfo.policy_name" class="policy-tag">{{ storageInfo.policy_name }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="storageInfo.used_percent" :stroke-width="8"
|
||||
:color="storageInfo.used_percent > 80 ? '#f56c6c' : '#409eff'"
|
||||
:format="() => formatSize(storageInfo.used_storage) + ' / ' + formatSize(storageInfo.storage_quota)" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="content">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item v-for="(item, index) in fileStore.breadcrumbs" :key="item.id">
|
||||
<a href="#" @click.prevent="fileStore.navigateTo(index)" class="breadcrumb-link">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索文件..." clearable style="width: 220px"
|
||||
@keyup.enter="handleSearch" @clear="handleClearSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-dropdown @command="handleCommand" trigger="click">
|
||||
<el-avatar :size="32" class="user-avatar">
|
||||
{{ (userStore.nickname || userStore.username || 'U').charAt(0) }}
|
||||
</el-avatar>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>
|
||||
<strong>{{ userStore.nickname || userStore.username }}</strong>
|
||||
<el-tag size="small" style="margin-left: 8px">{{ userStore.role }}</el-tag>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="password">修改密码</el-dropdown-item>
|
||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-button v-if="fileStore.currentFolderId > 0" @click="handleGoBack" style="margin-right: 8px">
|
||||
<el-icon><Back /></el-icon> 返回上一级
|
||||
</el-button>
|
||||
<el-button-group>
|
||||
<el-button v-if="userStore.hasPermission('file:upload')" type="primary" @click="showUploadDialog = true">
|
||||
<el-icon><Upload /></el-icon> 上传
|
||||
</el-button>
|
||||
<el-button v-if="userStore.hasPermission('file:create')" @click="showCreateFolderDialog = true">
|
||||
<el-icon><FolderAdd /></el-icon> 新建文件夹
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button :icon="Refresh" circle @click="fileStore.refresh()" />
|
||||
<el-radio-group v-model="viewMode" size="small">
|
||||
<el-radio-button value="list"><el-icon><List /></el-icon></el-radio-button>
|
||||
<el-radio-button value="grid"><el-icon><Grid /></el-icon></el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表视图 -->
|
||||
<div class="file-content" v-loading="fileStore.loading">
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-if="tableData.length === 0 && !fileStore.loading" description="此文件夹为空">
|
||||
<el-button type="primary" @click="showUploadDialog = true">上传文件</el-button>
|
||||
</el-empty>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<el-table v-else-if="viewMode === 'list'" :data="tableData" :show-header="true"
|
||||
@row-dblclick="handleRowDblClick" row-class-name="file-row">
|
||||
<el-table-column label="名称" min-width="400">
|
||||
<template #default="{ row }">
|
||||
<div class="file-name-cell" :class="{ 'folder-clickable': row.isFolder }"
|
||||
@click="row.isFolder && handleEnterFolder(row)">
|
||||
<el-icon v-if="row.isFolder" :size="22" class="folder-icon"><Folder /></el-icon>
|
||||
<el-icon v-else :size="22" class="file-icon" :style="{ color: getFileColor(row.extension) }">
|
||||
<Document />
|
||||
</el-icon>
|
||||
<span class="file-name">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ row.isFolder ? '-' : formatSize(row.size) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="修改时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.updated_at || row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-tooltip content="重命名">
|
||||
<el-button link type="primary" @click.stop="handleRename(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="下载" v-if="!row.isFolder">
|
||||
<el-button link type="primary" @click.stop="handleDownload(row)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="分享" v-if="!row.isFolder && userStore.hasPermission('share:create')">
|
||||
<el-button link type="primary" @click.stop="handleShare(row)">
|
||||
<el-icon><Share /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" v-if="userStore.hasPermission('file:delete')">
|
||||
<el-button link type="danger" @click.stop="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 网格视图 -->
|
||||
<div v-else class="grid-view">
|
||||
<div v-for="row in tableData" :key="(row.isFolder ? 'f' : 'file') + row.id"
|
||||
class="grid-item" @dblclick="row.isFolder ? handleEnterFolder(row) : handleDownload(row)">
|
||||
<div class="grid-item-icon">
|
||||
<el-icon v-if="row.isFolder" :size="48" class="folder-icon"><Folder /></el-icon>
|
||||
<el-icon v-else :size="48" :style="{ color: getFileColor(row.extension) }"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="grid-item-name">{{ row.name }}</div>
|
||||
<div class="grid-item-size">{{ row.isFolder ? '' : formatSize(row.size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传对话框 -->
|
||||
<el-dialog v-model="showUploadDialog" title="上传文件" width="520" :close-on-click-modal="false">
|
||||
<el-upload drag :auto-upload="false" :file-list="uploadFiles" :on-change="handleUploadChange"
|
||||
:on-remove="handleUploadRemove" multiple>
|
||||
<el-icon class="el-icon--upload" :size="48"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">拖拽文件到此处,或 <em>点击选择</em></div>
|
||||
<template #tip><div class="el-upload__tip">支持批量上传,单文件最大 200MB</div></template>
|
||||
</el-upload>
|
||||
<template #footer>
|
||||
<el-button @click="showUploadDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="handleUpload">
|
||||
{{ uploading ? `上传中 (${uploadProgress}/${uploadFiles.length})` : '开始上传' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建文件夹对话框 -->
|
||||
<el-dialog v-model="showCreateFolderDialog" title="新建文件夹" width="400">
|
||||
<el-input v-model="newFolderName" placeholder="请输入文件夹名称" @keyup.enter="handleCreateFolder"
|
||||
autofocus clearable />
|
||||
<template #footer>
|
||||
<el-button @click="showCreateFolderDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreateFolder">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重命名对话框 -->
|
||||
<el-dialog v-model="showRenameDialog" title="重命名" width="400">
|
||||
<el-input v-model="renameForm.new_name" placeholder="请输入新名称" @keyup.enter="confirmRename" autofocus clearable />
|
||||
<template #footer>
|
||||
<el-button @click="showRenameDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmRename">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分享对话框 -->
|
||||
<el-dialog v-model="showShareDialog" title="创建分享链接" width="480">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="文件">
|
||||
<el-text type="primary">{{ shareTarget?.name }}</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item label="提取密码">
|
||||
<el-input v-model="shareForm.password" placeholder="留空则无密码" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期">
|
||||
<el-select v-model="shareForm.expireHours" style="width: 100%">
|
||||
<el-option :value="0" label="永久有效" />
|
||||
<el-option :value="1" label="1 小时" />
|
||||
<el-option :value="24" label="1 天" />
|
||||
<el-option :value="168" label="7 天" />
|
||||
<el-option :value="720" label="30 天" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大下载次数">
|
||||
<el-input-number v-model="shareForm.maxDownload" :min="0" style="width: 100%" />
|
||||
<div class="form-tip">0 = 不限制</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="访问权限">
|
||||
<el-checkbox v-model="shareForm.allowPreview">允许预览</el-checkbox>
|
||||
<el-checkbox v-model="shareForm.allowDownload">允许下载</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="shareResult" class="share-result">
|
||||
<el-alert type="success" :closable="false" show-icon>
|
||||
<template #title>分享链接已创建</template>
|
||||
<template #default>
|
||||
<el-input :model-value="shareResult.share_url" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyShareUrl">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showShareDialog = false">{{ shareResult ? '关闭' : '取消' }}</el-button>
|
||||
<el-button v-if="!shareResult" type="primary" @click="handleCreateShare">创建分享</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog v-model="showPasswordDialog" title="修改密码" width="420">
|
||||
<el-form :model="passwordForm" label-width="100px">
|
||||
<el-form-item label="当前密码" required>
|
||||
<el-input v-model="passwordForm.old_password" type="password" show-password placeholder="输入当前密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input v-model="passwordForm.new_password" type="password" show-password placeholder="至少6位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码" required>
|
||||
<el-input v-model="passwordForm.confirm_password" type="password" show-password placeholder="再次输入新密码" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="changingPassword" @click="handleChangePassword">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import {
|
||||
Refresh, Search, Upload, FolderAdd, Folder, Document, UploadFilled,
|
||||
HomeFilled, Share, Delete, Download, List, Grid, Files, Back, Setting, Edit,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useFileStore } from '../stores/file'
|
||||
import request from '../api/request'
|
||||
import {
|
||||
uploadFile, createFolder, deleteFile, deleteFolder,
|
||||
getDownloadUrl, createShare, searchFiles,
|
||||
type FileInfo, type ShareInfo,
|
||||
} from '../api/file'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const fileStore = useFileStore()
|
||||
const route = useRoute()
|
||||
|
||||
const viewMode = ref<'list' | 'grid'>('list')
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref<FileInfo[]>([])
|
||||
const isSearching = ref(false)
|
||||
|
||||
const showUploadDialog = ref(false)
|
||||
const uploadFiles = ref<UploadFile[]>([])
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
|
||||
const showCreateFolderDialog = ref(false)
|
||||
const newFolderName = ref('')
|
||||
|
||||
const showShareDialog = ref(false)
|
||||
const shareTarget = ref<FileInfo | null>(null)
|
||||
const shareForm = ref({ password: '', expireHours: 0, maxDownload: 0, allowPreview: true, allowDownload: true })
|
||||
const shareResult = ref<ShareInfo | null>(null)
|
||||
|
||||
// 修改密码
|
||||
const showPasswordDialog = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const passwordForm = ref({ old_password: '', new_password: '', confirm_password: '' })
|
||||
|
||||
// 重命名
|
||||
const showRenameDialog = ref(false)
|
||||
const renameTarget = ref<any>(null)
|
||||
const renameForm = ref({ new_name: '', version: 0 })
|
||||
|
||||
const tableData = computed(() => {
|
||||
if (isSearching.value) return searchResults.value.map(f => ({ ...f, isFolder: false }))
|
||||
return [
|
||||
...fileStore.folders.map(f => ({ ...f, isFolder: true })),
|
||||
...fileStore.files.map(f => ({ ...f, isFolder: false })),
|
||||
]
|
||||
})
|
||||
|
||||
const storageInfo = ref({
|
||||
policy_name: '',
|
||||
storage_quota: 0,
|
||||
used_storage: 0,
|
||||
used_percent: 0,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (userStore.token) {
|
||||
fileStore.loadFolder(0)
|
||||
userStore.loadPermissions()
|
||||
loadStorageInfo()
|
||||
}
|
||||
// 首次登录强制改密码
|
||||
if (route.query.change_pwd === '1') {
|
||||
passwordForm.value = { old_password: '', new_password: '', confirm_password: '' }
|
||||
showPasswordDialog.value = true
|
||||
}
|
||||
})
|
||||
|
||||
async function loadStorageInfo() {
|
||||
try {
|
||||
const res = await request.get('/user/storage-info') as any
|
||||
storageInfo.value = res.data || storageInfo.value
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function getFileColor(ext: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
pdf: '#e74c3c', doc: '#2980b9', docx: '#2980b9', xls: '#27ae60', xlsx: '#27ae60',
|
||||
ppt: '#e67e22', pptx: '#e67e22', jpg: '#9b59b6', jpeg: '#9b59b6', png: '#9b59b6',
|
||||
gif: '#9b59b6', mp4: '#e91e63', mp3: '#00bcd4', zip: '#795548', rar: '#795548',
|
||||
txt: '#607d8b', md: '#607d8b', json: '#ff9800', js: '#ffc107', ts: '#3f51b5',
|
||||
go: '#00bcd4', java: '#f44336', py: '#4caf50',
|
||||
}
|
||||
return colors[ext] || '#909399'
|
||||
}
|
||||
|
||||
function handleEnterFolder(row: any) { fileStore.enterFolder(row.id, row.name) }
|
||||
function handleGoBack() {
|
||||
const crumbs = fileStore.breadcrumbs
|
||||
if (crumbs.length > 1) {
|
||||
fileStore.navigateTo(crumbs.length - 2)
|
||||
}
|
||||
}
|
||||
function handleRowDblClick(row: any) { row.isFolder ? handleEnterFolder(row) : handleDownload(row) }
|
||||
async function handleDownload(file: FileInfo) {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`/api/file/${file.id}/download`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
ElMessage.error(err.message || '下载失败')
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchKeyword.value.trim()) return
|
||||
isSearching.value = true
|
||||
try { const res = await searchFiles(searchKeyword.value); searchResults.value = res.data || [] } catch (e) {}
|
||||
}
|
||||
function handleClearSearch() { isSearching.value = false; searchResults.value = [] }
|
||||
|
||||
function handleUploadChange(file: UploadFile) { uploadFiles.value.push(file) }
|
||||
function handleUploadRemove(file: UploadFile) { uploadFiles.value = uploadFiles.value.filter(f => f.uid !== file.uid) }
|
||||
|
||||
async function handleUpload() {
|
||||
if (!uploadFiles.value.length) { ElMessage.warning('请选择文件'); return }
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
let ok = 0
|
||||
for (const item of uploadFiles.value) {
|
||||
try { await uploadFile(item.raw!, fileStore.currentFolderId); ok++ } catch (e) {}
|
||||
uploadProgress.value++
|
||||
}
|
||||
uploading.value = false
|
||||
showUploadDialog.value = false
|
||||
uploadFiles.value = []
|
||||
if (ok > 0) { ElMessage.success(`成功上传 ${ok} 个文件`); fileStore.refresh() }
|
||||
}
|
||||
|
||||
async function handleCreateFolder() {
|
||||
if (!newFolderName.value.trim()) { ElMessage.warning('请输入文件夹名称'); return }
|
||||
try {
|
||||
await createFolder(newFolderName.value, fileStore.currentFolderId)
|
||||
ElMessage.success('创建成功')
|
||||
showCreateFolderDialog.value = false
|
||||
newFolderName.value = ''
|
||||
fileStore.refresh()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
const type = row.isFolder ? '文件夹' : '文件'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除${type}「${row.name}」?`, '确认删除', { type: 'warning' })
|
||||
row.isFolder ? await deleteFolder(row.id) : await deleteFile(row.id)
|
||||
ElMessage.success('已移入回收站')
|
||||
fileStore.refresh()
|
||||
} catch (e) { if (e !== 'cancel') {} }
|
||||
}
|
||||
|
||||
function handleShare(file: FileInfo) {
|
||||
shareTarget.value = file
|
||||
shareForm.value = { password: '', expireHours: 0, maxDownload: 0, allowPreview: true, allowDownload: true }
|
||||
shareResult.value = null
|
||||
showShareDialog.value = true
|
||||
}
|
||||
|
||||
/** 打开重命名对话框 */
|
||||
function handleRename(row: any) {
|
||||
renameTarget.value = row
|
||||
renameForm.value = { new_name: row.name, version: row.version || 0 }
|
||||
showRenameDialog.value = true
|
||||
}
|
||||
|
||||
/** 确认重命名 */
|
||||
async function confirmRename() {
|
||||
if (!renameForm.value.new_name.trim()) {
|
||||
ElMessage.warning('请输入新名称')
|
||||
return
|
||||
}
|
||||
if (renameForm.value.new_name === renameTarget.value.name) {
|
||||
showRenameDialog.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const id = renameTarget.value.id
|
||||
const isFolder = renameTarget.value.isFolder
|
||||
const apiPath = isFolder ? `/folder/${id}/rename` : `/file/${id}/rename`
|
||||
|
||||
await request.put(apiPath, {
|
||||
new_name: renameForm.value.new_name,
|
||||
version: renameForm.value.version,
|
||||
})
|
||||
|
||||
ElMessage.success('重命名成功')
|
||||
showRenameDialog.value = false
|
||||
fileStore.refresh()
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
ElMessage.warning('文件已被其他人修改, 请刷新后重试')
|
||||
fileStore.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateShare() {
|
||||
if (!shareTarget.value) return
|
||||
try {
|
||||
const res = await createShare(
|
||||
shareTarget.value.id,
|
||||
shareForm.value.password || undefined,
|
||||
shareForm.value.expireHours || undefined,
|
||||
shareForm.value.maxDownload || undefined,
|
||||
shareForm.value.allowPreview ? 1 : 0,
|
||||
shareForm.value.allowDownload ? 1 : 0,
|
||||
)
|
||||
shareResult.value = res.data
|
||||
ElMessage.success('分享链接已创建')
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function copyShareUrl() {
|
||||
if (shareResult.value) {
|
||||
navigator.clipboard.writeText(shareResult.value.share_url)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommand(cmd: string) {
|
||||
if (cmd === 'logout') { userStore.logout(); return }
|
||||
if (cmd === 'password') {
|
||||
passwordForm.value = { old_password: '', new_password: '', confirm_password: '' }
|
||||
showPasswordDialog.value = true
|
||||
return
|
||||
}
|
||||
if (cmd === 'trash' || cmd === 'shares') { ElMessage.info('功能开发中') }
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
const f = passwordForm.value
|
||||
if (!f.old_password) { ElMessage.warning('请输入当前密码'); return }
|
||||
if (!f.new_password || f.new_password.length < 6) { ElMessage.warning('新密码至少6位'); return }
|
||||
if (f.new_password !== f.confirm_password) { ElMessage.warning('两次输入的新密码不一致'); return }
|
||||
if (f.old_password === f.new_password) { ElMessage.warning('新密码不能与当前密码相同'); return }
|
||||
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await request.put('/auth/password', {
|
||||
old_password: f.old_password,
|
||||
new_password: f.new_password,
|
||||
})
|
||||
ElMessage.success('密码修改成功, 请重新登录')
|
||||
showPasswordDialog.value = false
|
||||
setTimeout(() => userStore.logout(), 1500)
|
||||
} catch (e) {} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-manager { display: flex; height: 100vh; background: #f0f2f5; }
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar { width: 220px; background: #1e293b; color: #cbd5e1; display: flex; flex-direction: column; flex-shrink: 0; }
|
||||
.sidebar-logo { height: 56px; display: flex; align-items: center; gap: 10px; padding: 0 20px; font-size: 16px; font-weight: 600; color: #fff; border-bottom: 1px solid #334155; }
|
||||
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
|
||||
.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; cursor: pointer; font-size: 14px; transition: all 0.2s; }
|
||||
.nav-item:hover { background: #334155; color: #fff; }
|
||||
.nav-item.active { background: #3b82f6; color: #fff; border-radius: 0; }
|
||||
.nav-divider { height: 1px; background: #334155; margin: 8px 16px; }
|
||||
.nav-section-title { padding: 8px 20px 4px; font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.sidebar-footer { padding: 16px 20px; border-top: 1px solid #334155; }
|
||||
.storage-label { font-size: 12px; color: #94a3b8; margin-bottom: 6px; }
|
||||
|
||||
/* 主内容 */
|
||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.header { height: 56px; background: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #e5e7eb; }
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
.user-avatar { cursor: pointer; background: #3b82f6; color: #fff; font-size: 14px; }
|
||||
.breadcrumb-link { color: #3b82f6; text-decoration: none; font-size: 14px; }
|
||||
.breadcrumb-link:hover { text-decoration: underline; }
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: #fff; border-bottom: 1px solid #e5e7eb; }
|
||||
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* 文件内容区 */
|
||||
.file-content { flex: 1; overflow: auto; padding: 16px 24px; }
|
||||
|
||||
/* 文件名单元格 */
|
||||
.file-name-cell { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
|
||||
.folder-clickable { cursor: pointer; }
|
||||
.folder-clickable .file-name { color: #3b82f6; }
|
||||
.folder-clickable:hover .file-name { text-decoration: underline; }
|
||||
.file-name { font-size: 14px; }
|
||||
.file-icon, .folder-icon { flex-shrink: 0; }
|
||||
.folder-icon { color: #f59e0b; }
|
||||
|
||||
/* 行操作按钮 */
|
||||
.row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
|
||||
.file-row:hover .row-actions { opacity: 1; }
|
||||
|
||||
/* 网格视图 */
|
||||
.grid-view { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 16px; }
|
||||
.grid-item { background: #fff; border-radius: 8px; padding: 20px 12px 12px; text-align: center; cursor: pointer; transition: all 0.2s; border: 1px solid transparent; }
|
||||
.grid-item:hover { border-color: #3b82f6; box-shadow: 0 2px 8px rgba(59,130,246,0.15); }
|
||||
.grid-item-icon { margin-bottom: 8px; }
|
||||
.grid-item-name { font-size: 13px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.grid-item-size { font-size: 11px; color: #999; margin-top: 4px; }
|
||||
|
||||
.text-secondary { color: #909399; font-size: 13px; }
|
||||
.form-tip { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.share-result { margin-top: 16px; }
|
||||
.policy-tag { font-size: 11px; background: #e0f2fe; color: #0284c7; padding: 1px 6px; border-radius: 4px; margin-left: 6px; }
|
||||
</style>
|
||||
139
web/src/views/Login.vue
Normal file
139
web/src/views/Login.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg"></div>
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<el-icon :size="40" color="#3b82f6"><Files /></el-icon>
|
||||
<h1>文件管理系统</h1>
|
||||
<p>安全、高效的云端文件管理平台</p>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="login-tabs">
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form :model="loginForm" :rules="rules" ref="loginFormRef" @submit.prevent="handleLogin" size="large">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="loginForm.username" placeholder="用户名" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="loginForm.password" placeholder="密码" prefix-icon="Lock"
|
||||
type="password" show-password @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="ssoProviders.length" class="sso-section">
|
||||
<el-divider>其他登录方式</el-divider>
|
||||
<div class="sso-list">
|
||||
<el-button v-for="p in ssoProviders" :key="p.id" size="large" style="width: 100%; margin-bottom: 8px"
|
||||
@click="handleSSOLogin(p.id)">
|
||||
{{ p.display_name }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form :model="registerForm" :rules="registerRules" ref="registerFormRef" @submit.prevent="handleRegister" size="large">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="registerForm.username" placeholder="用户名 (3-64字符)" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="registerForm.password" placeholder="密码 (至少6位)" prefix-icon="Lock"
|
||||
type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item prop="nickname">
|
||||
<el-input v-model="registerForm.nickname" placeholder="昵称 (可选)" prefix-icon="UserFilled" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleRegister" style="width: 100%">
|
||||
{{ loading ? '注册中...' : '注 册' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, type FormInstance } from 'element-plus'
|
||||
import { Files } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { register, getSSOAuthURL, getSSOProviders, type SSOProvider } from '../api/auth'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const activeTab = ref('login')
|
||||
const loading = ref(false)
|
||||
const ssoProviders = ref<SSOProvider[]>([])
|
||||
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loginForm = reactive({ username: '', password: '' })
|
||||
|
||||
const registerFormRef = ref<FormInstance>()
|
||||
const registerForm = reactive({ username: '', password: '', nickname: '' })
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
const registerRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }, { min: 3, max: 64, message: '3-64字符', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, message: '至少6位', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try { const res = await getSSOProviders(); ssoProviders.value = res.data || [] } catch (e) {}
|
||||
const ssoToken = route.query.token as string
|
||||
const ssoUsername = route.query.username as string
|
||||
if (ssoToken && ssoUsername) {
|
||||
userStore.ssoLogin({ token: ssoToken, username: ssoUsername, nickname: ssoUsername, role: 'user' })
|
||||
ElMessage.success('SSO登录成功')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
const valid = await loginFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try { await userStore.login(loginForm); ElMessage.success('登录成功') } catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
const valid = await registerFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await register(registerForm)
|
||||
ElMessage.success('注册成功, 请登录')
|
||||
activeTab.value = 'login'
|
||||
loginForm.username = registerForm.username
|
||||
} catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function handleSSOLogin(id: number) {
|
||||
try { const res = await getSSOAuthURL(id); window.location.href = res.data.auth_url }
|
||||
catch (e) { ElMessage.error('获取SSO授权地址失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
|
||||
.login-bg { position: absolute; inset: 0; background: linear-gradient(135deg, #1e3a5f 0%, #3b82f6 50%, #8b5cf6 100%); }
|
||||
.login-bg::after { content: ''; position: absolute; inset: 0; background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.login-card { position: relative; z-index: 1; width: 420px; padding: 40px; background: rgba(255,255,255,0.95); border-radius: 16px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); backdrop-filter: blur(10px); }
|
||||
.login-header { text-align: center; margin-bottom: 30px; }
|
||||
.login-header h1 { font-size: 24px; color: #1e293b; margin: 12px 0 6px; }
|
||||
.login-header p { font-size: 14px; color: #94a3b8; }
|
||||
.sso-section { margin-top: 8px; }
|
||||
.sso-list { display: flex; flex-direction: column; }
|
||||
</style>
|
||||
251
web/src/views/ShareView.vue
Normal file
251
web/src/views/ShareView.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="share-page">
|
||||
<!-- 密码验证 -->
|
||||
<div v-if="needPassword && !verified" class="password-card">
|
||||
<div class="card-icon">
|
||||
<el-icon :size="48" color="#3b82f6"><Lock /></el-icon>
|
||||
</div>
|
||||
<h2>需要提取密码</h2>
|
||||
<p>此分享链接需要输入密码才能访问</p>
|
||||
<el-input v-model="password" placeholder="请输入提取密码" size="large"
|
||||
@keyup.enter="handleVerify" style="margin: 20px 0">
|
||||
<template #append>
|
||||
<el-button @click="handleVerify" :loading="verifying">验证</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-else-if="loading" class="loading-state">
|
||||
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<el-icon :size="48" color="#f56c6c"><CircleCloseFilled /></el-icon>
|
||||
<h3>{{ error }}</h3>
|
||||
<p>链接可能已过期或被取消</p>
|
||||
</div>
|
||||
|
||||
<!-- 文件预览 -->
|
||||
<div v-else-if="fileInfo" class="preview-container">
|
||||
<!-- 顶部信息栏 -->
|
||||
<header class="preview-header">
|
||||
<div class="file-info">
|
||||
<el-icon :size="20" :style="{ color: getFileColor(fileInfo.extension) }"><Document /></el-icon>
|
||||
<span class="file-name">{{ fileInfo.name }}</span>
|
||||
<span class="file-size">{{ formatSize(fileInfo.size) }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button v-if="fileInfo.allow_download" type="primary" @click="handleDownload">
|
||||
<el-icon><Download /></el-icon> 下载
|
||||
</el-button>
|
||||
<el-button v-if="!fileInfo.allow_download" disabled>
|
||||
<el-icon><Lock /></el-icon> 禁止下载
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 预览内容区 -->
|
||||
<main class="preview-content">
|
||||
<!-- 图片预览 -->
|
||||
<div v-if="isImage" class="preview-image">
|
||||
<img :src="previewSrc" :alt="fileInfo.name" />
|
||||
</div>
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<div v-else-if="isVideo" class="preview-video">
|
||||
<video controls :src="previewSrc" style="max-width: 100%; max-height: 80vh">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- 音频预览 -->
|
||||
<div v-else-if="isAudio" class="preview-audio">
|
||||
<div class="audio-cover">
|
||||
<el-icon :size="80" color="#909399"><Headset /></el-icon>
|
||||
<p>{{ fileInfo.name }}</p>
|
||||
</div>
|
||||
<audio controls :src="previewSrc" style="width: 100%; max-width: 500px">
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- PDF预览 -->
|
||||
<div v-else-if="isPDF" class="preview-pdf">
|
||||
<iframe :src="previewSrc" frameborder="0"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- 文本/代码预览 -->
|
||||
<div v-else-if="isText" class="preview-text">
|
||||
<pre>{{ textContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 不支持预览 -->
|
||||
<div v-else class="preview-unsupported">
|
||||
<el-icon :size="64" color="#909399"><Document /></el-icon>
|
||||
<h3>此文件类型不支持在线预览</h3>
|
||||
<p>{{ fileInfo.name }} ({{ fileInfo.extension }})</p>
|
||||
<el-button v-if="fileInfo.allow_download" type="primary" @click="handleDownload" style="margin-top: 16px">
|
||||
下载文件
|
||||
</el-button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading, CircleCloseFilled, Document, Download, Lock, Headset } from '@element-plus/icons-vue'
|
||||
import { getShareInfo, verifySharePassword } from '../api/file'
|
||||
|
||||
const route = useRoute()
|
||||
const code = route.params.code as string
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const fileInfo = ref<any>(null)
|
||||
const needPassword = ref(false)
|
||||
const verified = ref(false)
|
||||
const password = ref('')
|
||||
const verifying = ref(false)
|
||||
const textContent = ref('')
|
||||
|
||||
const previewSrc = computed(() => {
|
||||
if (!fileInfo.value) return ''
|
||||
// 使用预览接口(带密码参数)
|
||||
const pwdParam = password.value ? `?password=${password.value}` : ''
|
||||
return `/api/s/${code}/preview${pwdParam}`
|
||||
})
|
||||
|
||||
// 文件类型判断
|
||||
const isImage = computed(() => {
|
||||
const ext = fileInfo.value?.extension?.toLowerCase()
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'].includes(ext)
|
||||
})
|
||||
const isVideo = computed(() => {
|
||||
const ext = fileInfo.value?.extension?.toLowerCase()
|
||||
return ['mp4', 'webm', 'ogg', 'mov'].includes(ext)
|
||||
})
|
||||
const isAudio = computed(() => {
|
||||
const ext = fileInfo.value?.extension?.toLowerCase()
|
||||
return ['mp3', 'wav', 'ogg', 'aac', 'flac'].includes(ext)
|
||||
})
|
||||
const isPDF = computed(() => fileInfo.value?.extension?.toLowerCase() === 'pdf')
|
||||
const isText = computed(() => {
|
||||
const ext = fileInfo.value?.extension?.toLowerCase()
|
||||
return ['txt', 'md', 'json', 'xml', 'csv', 'log', 'ini', 'yaml', 'yml', 'toml',
|
||||
'js', 'ts', 'py', 'go', 'java', 'c', 'cpp', 'h', 'css', 'html', 'sql', 'sh', 'bat'].includes(ext)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getShareInfo(code)
|
||||
const data = res.data as any
|
||||
|
||||
fileInfo.value = data.file
|
||||
needPassword.value = data.has_password
|
||||
|
||||
if (!needPassword.value) {
|
||||
verified.value = true
|
||||
await loadTextPreview()
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '分享链接不存在或已失效'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function handleVerify() {
|
||||
if (!password.value) {
|
||||
ElMessage.warning('请输入密码')
|
||||
return
|
||||
}
|
||||
verifying.value = true
|
||||
try {
|
||||
await verifySharePassword(code, password.value)
|
||||
verified.value = true
|
||||
ElMessage.success('验证成功')
|
||||
await loadTextPreview()
|
||||
} catch (e) {} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载文本文件内容(用于代码/文本预览) */
|
||||
async function loadTextPreview() {
|
||||
if (!isText.value || !fileInfo.value?.allow_preview) return
|
||||
try {
|
||||
const pwdParam = password.value ? `?password=${password.value}` : ''
|
||||
const res = await fetch(`/api/s/${code}/preview${pwdParam}`)
|
||||
if (res.ok) {
|
||||
textContent.value = await res.text()
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const pwdParam = password.value ? `?password=${password.value}` : ''
|
||||
window.open(`/api/s/${code}/download${pwdParam}`, '_blank')
|
||||
}
|
||||
|
||||
function getFileColor(ext: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
pdf: '#e74c3c', doc: '#2980b9', docx: '#2980b9', xls: '#27ae60', xlsx: '#27ae60',
|
||||
ppt: '#e67e22', pptx: '#e67e22', jpg: '#9b59b6', png: '#9b59b6', mp4: '#e91e63',
|
||||
mp3: '#00bcd4', zip: '#795548', txt: '#607d8b',
|
||||
}
|
||||
return colors[ext] || '#909399'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.share-page { min-height: 100vh; background: #f0f2f5; display: flex; flex-direction: column; }
|
||||
|
||||
.password-card { max-width: 420px; margin: auto; padding: 48px 40px; background: #fff; border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08); text-align: center; }
|
||||
.password-card h2 { margin: 16px 0 8px; color: #1e293b; }
|
||||
.password-card p { color: #94a3b8; font-size: 14px; }
|
||||
|
||||
.loading-state, .error-state { margin: auto; text-align: center; color: #909399; }
|
||||
.error-state h3 { margin: 16px 0 8px; color: #f56c6c; }
|
||||
|
||||
.preview-container { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
.preview-header { height: 56px; background: #fff; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; }
|
||||
.file-info { display: flex; align-items: center; gap: 10px; }
|
||||
.file-name { font-size: 15px; font-weight: 600; color: #1e293b; }
|
||||
.file-size { font-size: 13px; color: #94a3b8; margin-left: 4px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
|
||||
.preview-content { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 20px; }
|
||||
|
||||
.preview-image img { max-width: 100%; max-height: 80vh; object-fit: contain; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }
|
||||
.preview-video video { border-radius: 8px; }
|
||||
.preview-audio { text-align: center; }
|
||||
.audio-cover { margin-bottom: 20px; }
|
||||
.audio-cover p { margin-top: 12px; color: #606266; }
|
||||
|
||||
.preview-pdf { width: 100%; height: 100%; }
|
||||
.preview-pdf iframe { width: 100%; height: 100%; border-radius: 8px; background: #fff; }
|
||||
|
||||
.preview-text { width: 100%; max-width: 900px; background: #1e293b; border-radius: 8px; padding: 20px; overflow: auto; }
|
||||
.preview-text pre { color: #e2e8f0; font-family: 'Consolas', 'Monaco', monospace; font-size: 14px; line-height: 1.6;
|
||||
white-space: pre-wrap; word-wrap: break-word; margin: 0; }
|
||||
|
||||
.preview-unsupported { text-align: center; color: #909399; }
|
||||
.preview-unsupported h3 { margin: 16px 0 8px; color: #606266; }
|
||||
</style>
|
||||
142
web/src/views/admin/OrganizationManage.vue
Normal file
142
web/src/views/admin/OrganizationManage.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<!-- 组织架构管理页面 -->
|
||||
<div class="admin-page">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<el-button @click="$router.push('/')">返回文件管理</el-button>
|
||||
<h1 style="margin-left: 16px">组织架构</h1>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate(0)">新建顶级部门</el-button>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<el-card>
|
||||
<el-tree :data="departments" node-key="id" default-expand-all :expand-on-click-node="false"
|
||||
v-loading="loading" empty-text="暂无部门数据">
|
||||
<template #default="{ node, data }">
|
||||
<div class="tree-node">
|
||||
<span class="node-label">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
{{ data.name }}
|
||||
<el-tag v-if="data.code" size="small" type="info" style="margin-left: 8px">{{ data.code }}</el-tag>
|
||||
<el-tag v-if="data.status === 0" size="small" type="danger" style="margin-left: 4px">已禁用</el-tag>
|
||||
</span>
|
||||
<span class="node-actions">
|
||||
<el-button link type="primary" size="small" @click.stop="openCreate(data.id)">添加子部门</el-button>
|
||||
<el-button link type="primary" size="small" @click.stop="handleEdit(data)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click.stop="handleDelete(data)">删除</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-card>
|
||||
</main>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<el-dialog v-model="showDialog" :title="editing ? '编辑部门' : '新建部门'" width="450">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="部门名称" required>
|
||||
<el-input v-model="form.name" placeholder="如: 技术部" />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门编码">
|
||||
<el-input v-model="form.code" placeholder="如: TECH" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上级部门">
|
||||
<el-tree-select v-model="form.parent_id" :data="deptTreeOptions" :props="{ value: 'id', label: 'name', children: 'children' }"
|
||||
check-strictly clearable placeholder="无(顶级部门)" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { OfficeBuilding } from '@element-plus/icons-vue'
|
||||
import request from '../../api/request'
|
||||
|
||||
interface Dept {
|
||||
id: number; name: string; code: string; parent_id: number
|
||||
sort_order: number; status: number; children?: Dept[]
|
||||
}
|
||||
|
||||
const departments = ref<Dept[]>([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const editing = ref<Dept | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = ref({ name: '', code: '', parent_id: 0, sort_order: 0, status: 1 })
|
||||
|
||||
// 树选择器选项(添加一个"无"选项)
|
||||
const deptTreeOptions = computed(() => [{ id: 0, name: '无(顶级部门)', children: departments.value }])
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/departments') as any
|
||||
departments.value = res.data || []
|
||||
} catch (error) { /* 已处理 */ }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function openCreate(parentId: number) {
|
||||
editing.value = null
|
||||
form.value = { name: '', code: '', parent_id: parentId, sort_order: 0, status: 1 }
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function handleEdit(dept: Dept) {
|
||||
editing.value = dept
|
||||
form.value = { name: dept.name, code: dept.code || '', parent_id: dept.parent_id, sort_order: dept.sort_order, status: dept.status }
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name) { ElMessage.warning('请输入部门名称'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await request.put(`/admin/departments/${editing.value.id}`, form.value)
|
||||
} else {
|
||||
await request.post('/admin/departments', form.value)
|
||||
}
|
||||
ElMessage.success(editing.value ? '更新成功' : '创建成功')
|
||||
showDialog.value = false
|
||||
loadData()
|
||||
} catch (error) { /* 已处理 */ }
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
async function handleDelete(dept: Dept) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除部门 "${dept.name}" ?`, '确认删除', { type: 'warning' })
|
||||
await request.delete(`/admin/departments/${dept.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) { if (error !== 'cancel') { /* 已处理 */ } }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-page { min-height: 100vh; background: #f5f7fa; }
|
||||
.header { height: 60px; background: white; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.main { padding: 20px 24px; }
|
||||
.tree-node { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 4px 0; }
|
||||
.node-label { display: flex; align-items: center; gap: 6px; font-size: 14px; }
|
||||
.node-actions { margin-left: 20px; }
|
||||
</style>
|
||||
239
web/src/views/admin/RoleManage.vue
Normal file
239
web/src/views/admin/RoleManage.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<!-- 角色权限管理页面(管理员) -->
|
||||
<div class="admin-page">
|
||||
<header class="header">
|
||||
<h1>角色权限管理</h1>
|
||||
<el-button @click="$router.push('/')">返回文件管理</el-button>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧: 角色列表 -->
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>角色列表</span>
|
||||
<el-button type="primary" size="small" @click="showCreateRole = true">新建角色</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="roles" @row-click="selectRole" highlight-current-row>
|
||||
<el-table-column label="角色" prop="display_name" />
|
||||
<el-table-column label="标识" prop="name" width="120" />
|
||||
<el-table-column label="系统内置" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_system" type="info" size="small">内置</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" :disabled="row.is_system === 1"
|
||||
@click.stop="handleDeleteRole(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧: 权限配置 -->
|
||||
<el-col :span="14">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>权限配置 {{ selectedRole ? `- ${selectedRole.display_name}` : '' }}</span>
|
||||
</template>
|
||||
|
||||
<div v-if="!selectedRole" class="empty-tip">
|
||||
<el-empty description="请先选择一个角色" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 按资源分组展示权限 -->
|
||||
<div v-for="(perms, resource) in groupedPermissions" :key="resource" class="perm-group">
|
||||
<h4 class="perm-group-title">{{ resource }}</h4>
|
||||
<el-checkbox-group v-model="selectedPermIds">
|
||||
<el-checkbox v-for="perm in perms" :key="perm.id" :value="perm.id" border>
|
||||
{{ perm.name }} ({{ perm.code }})
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: right">
|
||||
<el-button type="primary" @click="handleSavePermissions">保存权限</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</main>
|
||||
|
||||
<!-- 新建角色对话框 -->
|
||||
<el-dialog v-model="showCreateRole" title="新建角色" width="400">
|
||||
<el-form :model="newRole" label-width="80px">
|
||||
<el-form-item label="标识" required>
|
||||
<el-input v-model="newRole.name" placeholder="如: editor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="newRole.display_name" placeholder="如: 编辑者" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="newRole.description" type="textarea" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateRole = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreateRole">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getRoles, createRole, setRolePermissions, getPermissions } from '../../api/auth'
|
||||
|
||||
interface Role {
|
||||
id: number
|
||||
name: string
|
||||
display_name: string
|
||||
description: string
|
||||
is_system: number
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
interface Permission {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
resource: string
|
||||
action: string
|
||||
}
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
const selectedRole = ref<Role | null>(null)
|
||||
const selectedPermIds = ref<number[]>([])
|
||||
const showCreateRole = ref(false)
|
||||
const newRole = ref({ name: '', display_name: '', description: '' })
|
||||
|
||||
/** 按资源分组的权限 */
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups: Record<string, Permission[]> = {}
|
||||
for (const perm of allPermissions.value) {
|
||||
if (!groups[perm.resource]) {
|
||||
groups[perm.resource] = []
|
||||
}
|
||||
groups[perm.resource].push(perm)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [roleRes, permRes] = await Promise.all([getRoles(), getPermissions()])
|
||||
roles.value = (roleRes as any).data || []
|
||||
allPermissions.value = (permRes as any).data || []
|
||||
} catch (error) {
|
||||
// 已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中角色 */
|
||||
function selectRole(role: Role) {
|
||||
selectedRole.value = role
|
||||
selectedPermIds.value = (role.permissions || []).map(p => p.id)
|
||||
}
|
||||
|
||||
/** 创建角色 */
|
||||
async function handleCreateRole() {
|
||||
if (!newRole.value.name || !newRole.value.display_name) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createRole(newRole.value)
|
||||
ElMessage.success('角色创建成功')
|
||||
showCreateRole.value = false
|
||||
newRole.value = { name: '', display_name: '', description: '' }
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
// 已由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除角色 */
|
||||
async function handleDeleteRole(role: Role) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除角色 "${role.display_name}" ?`, '确认')
|
||||
// TODO: 调用删除API
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
// 已由拦截器处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存权限 */
|
||||
async function handleSavePermissions() {
|
||||
if (!selectedRole.value) return
|
||||
try {
|
||||
await setRolePermissions(selectedRole.value.id, selectedPermIds.value)
|
||||
ElMessage.success('权限保存成功')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
// 已由拦截器处理
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.perm-group {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.perm-group-title {
|
||||
margin-bottom: 10px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
229
web/src/views/admin/StoragePolicyManage.vue
Normal file
229
web/src/views/admin/StoragePolicyManage.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
||||
<span>存储策略</span>
|
||||
<el-button type="primary" size="small" @click="openCreate">新建策略</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="policies" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="策略名称" width="150" />
|
||||
<el-table-column label="存储类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="策略类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="policyTypeColor(row.policy_type)">{{ policyTypeLabel(row.policy_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配置信息" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.type === 'local'">路径: {{ row.config?.base_path }}</span>
|
||||
<span v-else>{{ row.config?.endpoint }} / {{ row.config?.bucket }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleTest(row)">测试</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<el-dialog v-model="showDialog" :title="editing ? '编辑策略' : '新建策略'" width="550">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="策略名称" required>
|
||||
<el-input v-model="form.name" placeholder="如: 高性能热存储 / MinIO备份" />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储类型" required>
|
||||
<el-select v-model="form.type" style="width: 100%" :disabled="!!editing">
|
||||
<el-option label="本地磁盘" value="local" />
|
||||
<el-option label="MinIO" value="minio" />
|
||||
<el-option label="AWS S3" value="s3" />
|
||||
<el-option label="阿里云 OSS" value="oss" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="策略类型" required>
|
||||
<el-select v-model="form.policy_type" style="width: 100%">
|
||||
<el-option label="热存储 - 高频访问文件" value="hot" />
|
||||
<el-option label="冷存储 - 低频/备份文件" value="cold" />
|
||||
<el-option label="公开资源 - 静态资源/CDN" value="public" />
|
||||
<el-option label="扩展存储 - 从机/大文件" value="slave" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 本地存储配置 -->
|
||||
<template v-if="form.type === 'local'">
|
||||
<el-form-item label="存储路径">
|
||||
<el-input v-model="form.config.base_path" placeholder="D:/data/storage" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- MinIO/S3/OSS 配置 -->
|
||||
<template v-else>
|
||||
<el-form-item label="Endpoint" required>
|
||||
<el-input v-model="form.config.endpoint" placeholder="localhost:9000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Bucket" required>
|
||||
<el-input v-model="form.config.bucket" placeholder="my-bucket" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Access Key">
|
||||
<el-input v-model="form.config.access_key" placeholder="公开桶可留空" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Secret Key">
|
||||
<el-input v-model="form.config.secret_key" type="password" show-password placeholder="公开桶可留空" />
|
||||
</el-form-item>
|
||||
<el-form-item label="使用SSL">
|
||||
<el-switch v-model="form.config.use_ssl" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<el-alert v-if="testResult" :type="testResult.success ? 'success' : 'error'" :closable="true"
|
||||
style="margin-top: 12px" @close="testResult = null">
|
||||
{{ testResult.message }}
|
||||
<span v-if="testResult.latency_ms"> ({{ testResult.latency_ms }}ms)</span>
|
||||
</el-alert>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleTestConfig" :loading="testing">测试连接</el-button>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '../../api/request'
|
||||
|
||||
interface PolicyConfig { base_path?: string; endpoint?: string; bucket?: string; access_key?: string; secret_key?: string; region?: string; use_ssl?: boolean }
|
||||
interface Policy { id: number; name: string; type: string; policy_type: string; status: number; config: PolicyConfig; created_at: string }
|
||||
|
||||
const policies = ref<Policy[]>([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const editing = ref<Policy | null>(null)
|
||||
const submitting = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref<{ success: boolean; message: string; latency_ms?: number } | null>(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '', type: 'local', policy_type: 'hot', status: 1,
|
||||
config: { base_path: './data/storage', endpoint: '', bucket: '', access_key: '', secret_key: '', region: '', use_ssl: false } as PolicyConfig,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/storage-policies') as any
|
||||
policies.value = res.data || []
|
||||
} catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
form.value = defaultForm()
|
||||
testResult.value = null
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function handleEdit(row: Policy) {
|
||||
editing.value = row
|
||||
form.value = {
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
policy_type: row.policy_type || 'hot',
|
||||
status: row.status,
|
||||
config: { ...row.config, secret_key: '' },
|
||||
}
|
||||
testResult.value = null
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name) { ElMessage.warning('请输入策略名称'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await request.put(`/admin/storage-policies/${editing.value.id}`, form.value)
|
||||
} else {
|
||||
await request.post('/admin/storage-policies', form.value)
|
||||
}
|
||||
ElMessage.success(editing.value ? '更新成功' : '创建成功')
|
||||
showDialog.value = false
|
||||
loadData()
|
||||
} catch (e) {}
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
async function handleTest(row: Policy) {
|
||||
try {
|
||||
const res = await request.post(`/admin/storage-policies/${row.id}/test`) as any
|
||||
const r = res.data
|
||||
if (r.success) {
|
||||
ElMessage.success(`${r.message} (${r.latency_ms}ms)`)
|
||||
} else {
|
||||
ElMessage.error(r.message)
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function handleTestConfig() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const res = await request.post('/admin/storage-policies/test', {
|
||||
type: form.value.type,
|
||||
config: form.value.config,
|
||||
}) as any
|
||||
testResult.value = res.data
|
||||
} catch (e) {}
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
function policyTypeLabel(t: string): string {
|
||||
const m: Record<string, string> = { hot: '热存储', cold: '冷存储', public: '公开资源', slave: '扩展存储' }
|
||||
return m[t] || t || '-'
|
||||
}
|
||||
|
||||
function policyTypeColor(t: string): string {
|
||||
const m: Record<string, string> = { hot: 'danger', cold: 'info', public: 'success', slave: 'warning' }
|
||||
return m[t] || ''
|
||||
}
|
||||
|
||||
async function handleDelete(row: Policy) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除策略 "${row.name}" ?`, '确认删除', { type: 'warning' })
|
||||
await request.delete(`/admin/storage-policies/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (e) { if (e !== 'cancel') {} }
|
||||
}
|
||||
</script>
|
||||
268
web/src/views/admin/UserManage.vue
Normal file
268
web/src/views/admin/UserManage.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<!-- 账号管理页面 -->
|
||||
<div class="admin-page">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<el-button @click="$router.push('/')">返回文件管理</el-button>
|
||||
<h1 style="margin-left: 16px">账号管理</h1>
|
||||
</div>
|
||||
<el-button type="primary" @click="showCreateDialog = true">新建用户</el-button>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<el-table :data="users" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="nickname" label="昵称" width="120" />
|
||||
<el-table-column prop="email" label="邮箱" width="180" />
|
||||
<el-table-column label="角色" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="role in row.roles" :key="role.id" size="small" style="margin-right: 4px">
|
||||
{{ role.display_name }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="存储配额" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatSize(row.storage_quota) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已用空间" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ formatSize(row.used_storage) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="160" />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleResetPwd(row)">重置密码</el-button>
|
||||
<el-button link type="danger" size="small" :disabled="row.username === 'admin'" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination v-if="total > pageSize" :current-page="currentPage" :page-size="pageSize"
|
||||
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
|
||||
@current-change="handlePageChange" />
|
||||
</main>
|
||||
|
||||
<!-- 新建/编辑用户对话框 -->
|
||||
<el-dialog v-model="showCreateDialog" :title="editingUser ? '编辑用户' : '新建用户'" width="500">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" :disabled="!!editingUser" placeholder="3-64字符" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!editingUser" label="密码">
|
||||
<el-input v-model="form.password" type="password" placeholder="留空则使用默认密码 123456" show-password />
|
||||
<div class="form-tip">留空默认密码: 123456, 首次登录需修改</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="form.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role_ids" multiple style="width: 100%">
|
||||
<el-option v-for="role in allRoles" :key="role.id" :label="role.display_name" :value="role.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="存储配额">
|
||||
<el-input-number v-model="form.storage_quota_gb" :min="1" :max="10240" />
|
||||
<span style="margin-left: 8px; color: #999">GB</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置密码对话框 -->
|
||||
<el-dialog v-model="showResetPwdDialog" title="重置密码" width="400">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="用户">
|
||||
<el-input :value="resetPwdUser?.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input v-model="newPassword" type="password" placeholder="至少6位" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showResetPwdDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmResetPwd">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '../../api/request'
|
||||
import { getRoles } from '../../api/auth'
|
||||
|
||||
interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
email: string
|
||||
status: number
|
||||
storage_quota: number
|
||||
used_storage: number
|
||||
roles: { id: number; name: string; display_name: string }[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const users = ref<UserInfo[]>([])
|
||||
const allRoles = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 新建/编辑
|
||||
const showCreateDialog = ref(false)
|
||||
const editingUser = ref<UserInfo | null>(null)
|
||||
const submitting = ref(false)
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
role_ids: [] as number[],
|
||||
storage_quota_gb: 10,
|
||||
status: 1,
|
||||
})
|
||||
|
||||
// 重置密码
|
||||
const showResetPwdDialog = ref(false)
|
||||
const resetPwdUser = ref<UserInfo | null>(null)
|
||||
const newPassword = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadRoles()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get('/admin/users', { params: { page: currentPage.value, size: pageSize.value } }) as any
|
||||
users.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
} catch (error) { /* 已处理 */ }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const res = await getRoles()
|
||||
allRoles.value = (res as any).data || []
|
||||
} catch (error) { /* 已处理 */ }
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleEdit(user: UserInfo) {
|
||||
editingUser.value = user
|
||||
form.value = {
|
||||
username: user.username,
|
||||
password: '',
|
||||
nickname: user.nickname,
|
||||
email: user.email || '',
|
||||
role_ids: (user.roles || []).map(r => r.id),
|
||||
storage_quota_gb: Math.round(user.storage_quota / 1073741824),
|
||||
status: user.status,
|
||||
}
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.username) { ElMessage.warning('请输入用户名'); return }
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingUser.value) {
|
||||
await request.put(`/admin/users/${editingUser.value.id}`, {
|
||||
nickname: form.value.nickname,
|
||||
email: form.value.email,
|
||||
role_ids: form.value.role_ids,
|
||||
storage_quota: form.value.storage_quota_gb * 1073741824,
|
||||
status: form.value.status,
|
||||
})
|
||||
} else {
|
||||
await request.post('/admin/users', {
|
||||
username: form.value.username,
|
||||
password: form.value.password,
|
||||
nickname: form.value.nickname,
|
||||
email: form.value.email,
|
||||
role_ids: form.value.role_ids,
|
||||
storage_quota: form.value.storage_quota_gb * 1073741824,
|
||||
})
|
||||
}
|
||||
ElMessage.success(editingUser.value ? '更新成功' : '创建成功')
|
||||
showCreateDialog.value = false
|
||||
editingUser.value = null
|
||||
loadData()
|
||||
} catch (error) { /* 已处理 */ }
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
async function handleDelete(user: UserInfo) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除用户 "${user.username}" ?`, '确认删除', { type: 'warning' })
|
||||
await request.delete(`/admin/users/${user.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) { if (error !== 'cancel') { /* 已处理 */ } }
|
||||
}
|
||||
|
||||
function handleResetPwd(user: UserInfo) {
|
||||
resetPwdUser.value = user
|
||||
newPassword.value = ''
|
||||
showResetPwdDialog.value = true
|
||||
}
|
||||
|
||||
async function confirmResetPwd() {
|
||||
if (!newPassword.value || newPassword.value.length < 6) {
|
||||
ElMessage.warning('密码至少6位')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await request.put(`/admin/users/${resetPwdUser.value!.id}/password`, { password: newPassword.value })
|
||||
ElMessage.success('密码已重置')
|
||||
showResetPwdDialog.value = false
|
||||
} catch (error) { /* 已处理 */ }
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-page { min-height: 100vh; background: #f5f7fa; }
|
||||
.header { height: 60px; background: white; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.main { padding: 20px 24px; }
|
||||
.form-tip { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
</style>
|
||||
7
web/src/vite-env.d.ts
vendored
Normal file
7
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
24
web/tsconfig.json
Normal file
24
web/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
web/tsconfig.node.json
Normal file
10
web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
web/vite.config.ts
Normal file
19
web/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/storage': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user