初始化

This commit is contained in:
2026-07-03 15:58:29 +08:00
parent 3a942c882e
commit dc90e889e1
77 changed files with 12912 additions and 0 deletions

13
web/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

24
web/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}
})

View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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,
},
},
},
})