feat: 添加 Web UI 登录功能
为 Web UI 添加完整的登录验证系统,遵循 CQRS 架构模式。 主要功能: - 创建登录页面 UI (web/login.html) - 美观的渐变背景设计 - 密钥输入和验证 - 错误提示和加载状态 - 实现登录验证 (遵循 CQRS) - 新增 LoginQuery 和 LoginHandler (internal/api/handlers/auth_handlers.go) - 新增 AuthEndpoint (internal/api/endpoints/auth_endpoints.go) - 注册登录接口 /auth/login (无需授权) - 更新主页面 (web/index.html) - 添加登录状态检查 - 未登录显示提示信息 - 所有 API 请求自动携带 X-API-Key 头 - 添加退出登录功能 - 401 错误自动跳转登录页 - 更新路由配置 (cmd/server/main.go) - 添加 /auth/login 公开路由 - 注册登录处理器和端点 - 新增登录文档 (docs/LOGIN_GUIDE.md) - 完整的使用说明 - 技术实现细节 - API 接口说明 安全特性: - 密钥存储在 localStorage - 自动处理登录过期 - 支持主动退出登录 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
98a3701d54
commit
54f66c56ed
@ -44,6 +44,7 @@ func main() {
|
|||||||
uploadPartHandler := handlers.NewUploadPartHandler(s3Repo)
|
uploadPartHandler := handlers.NewUploadPartHandler(s3Repo)
|
||||||
completeMultipartHandler := handlers.NewCompleteMultipartHandler(s3Repo)
|
completeMultipartHandler := handlers.NewCompleteMultipartHandler(s3Repo)
|
||||||
deleteFileHandler := handlers.NewDeleteFileHandler(s3Repo)
|
deleteFileHandler := handlers.NewDeleteFileHandler(s3Repo)
|
||||||
|
loginHandler := handlers.NewLoginHandler(middleware.API_KEY_VALUE)
|
||||||
|
|
||||||
// Register Handlers
|
// Register Handlers
|
||||||
mediator.Register[handlers.UploadFileCommand, string](m, uploadHandler)
|
mediator.Register[handlers.UploadFileCommand, string](m, uploadHandler)
|
||||||
@ -58,6 +59,7 @@ func main() {
|
|||||||
mediator.Register[handlers.UploadPartCommand, string](m, uploadPartHandler)
|
mediator.Register[handlers.UploadPartCommand, string](m, uploadPartHandler)
|
||||||
mediator.Register[handlers.CompleteMultipartCommand, string](m, completeMultipartHandler)
|
mediator.Register[handlers.CompleteMultipartCommand, string](m, completeMultipartHandler)
|
||||||
mediator.Register[handlers.DeleteFileCommand, string](m, deleteFileHandler)
|
mediator.Register[handlers.DeleteFileCommand, string](m, deleteFileHandler)
|
||||||
|
mediator.Register[handlers.LoginQuery, handlers.LoginResult](m, loginHandler)
|
||||||
|
|
||||||
// Validators
|
// Validators
|
||||||
uploadValidator := validators.NewUploadFileValidator()
|
uploadValidator := validators.NewUploadFileValidator()
|
||||||
@ -68,6 +70,7 @@ func main() {
|
|||||||
// Endpoints
|
// Endpoints
|
||||||
fileEndpoint := endpoints.NewFileEndpoint(m, uploadValidator, downloadValidator, newFeaturesValidator)
|
fileEndpoint := endpoints.NewFileEndpoint(m, uploadValidator, downloadValidator, newFeaturesValidator)
|
||||||
bucketEndpoint := endpoints.NewBucketEndpoint(m, createBucketValidator)
|
bucketEndpoint := endpoints.NewBucketEndpoint(m, createBucketValidator)
|
||||||
|
authEndpoint := endpoints.NewAuthEndpoint(m)
|
||||||
|
|
||||||
// Router
|
// Router
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
@ -85,6 +88,9 @@ func main() {
|
|||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 公开接口(无需授权)
|
||||||
|
r.POST("/auth/login", authEndpoint.Login)
|
||||||
|
|
||||||
// API授权中间件组
|
// API授权中间件组
|
||||||
api := r.Group("/")
|
api := r.Group("/")
|
||||||
api.Use(middleware.AuthMiddleware())
|
api.Use(middleware.AuthMiddleware())
|
||||||
|
|||||||
174
docs/LOGIN_GUIDE.md
Normal file
174
docs/LOGIN_GUIDE.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# Web 登录功能说明
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
Web UI 现已添加密钥登录功能,用户必须先登录才能访问文件管理系统。
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
### 1. 访问登录页面
|
||||||
|
|
||||||
|
打开浏览器访问:
|
||||||
|
```
|
||||||
|
http://localhost:8080/web/login.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 输入 API 密钥
|
||||||
|
|
||||||
|
在登录页面输入 API 密钥:
|
||||||
|
```
|
||||||
|
xn001624.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 登录成功
|
||||||
|
|
||||||
|
登录成功后,系统会:
|
||||||
|
- 将密钥保存到浏览器的 localStorage
|
||||||
|
- 自动跳转到主页面 `/web/`
|
||||||
|
- 所有后续 API 请求都会自动携带密钥
|
||||||
|
|
||||||
|
### 4. 退出登录
|
||||||
|
|
||||||
|
点击右上角的"退出"按钮可以:
|
||||||
|
- 清除本地存储的密钥
|
||||||
|
- 跳转回登录页面
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 后端 (遵循 CQRS 模式)
|
||||||
|
|
||||||
|
1. **登录查询** (`internal/api/handlers/auth_handlers.go`)
|
||||||
|
```go
|
||||||
|
type LoginQuery struct {
|
||||||
|
APIKey string `json:"api_key" binding:"required"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **登录处理器**
|
||||||
|
```go
|
||||||
|
type LoginHandler struct {
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LoginHandler) Handle(ctx context.Context, query LoginQuery) (LoginResult, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **认证端点** (`internal/api/endpoints/auth_endpoints.go`)
|
||||||
|
```go
|
||||||
|
func (e *AuthEndpoint) Login(c *gin.Context)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **路由配置** (`cmd/server/main.go`)
|
||||||
|
- `/auth/login` - 公开接口,无需授权
|
||||||
|
- 所有其他 API 接口都需要 `X-API-Key` 请求头
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
1. **登录页面** (`web/login.html`)
|
||||||
|
- 美观的渐变背景登录界面
|
||||||
|
- 密钥输入框(密码类型)
|
||||||
|
- 表单验证和错误提示
|
||||||
|
- 登录后自动跳转
|
||||||
|
|
||||||
|
2. **主页面** (`web/index.html`)
|
||||||
|
- 检查 localStorage 中的 token
|
||||||
|
- 未登录显示提示信息
|
||||||
|
- 已登录正常显示文件管理界面
|
||||||
|
- 所有 API 请求自动携带 `X-API-Key` 头
|
||||||
|
- 401 错误自动跳转回登录页
|
||||||
|
|
||||||
|
## 安全特性
|
||||||
|
|
||||||
|
1. **前端存储**: 使用 localStorage 存储密钥(适合单用户场景)
|
||||||
|
2. **自动过期**: 当 API 返回 401 时自动清除密钥并跳转登录
|
||||||
|
3. **密钥保护**: 密钥输入框使用 password 类型,屏幕上不可见
|
||||||
|
4. **退出功能**: 支持主动退出登录
|
||||||
|
|
||||||
|
## 接口说明
|
||||||
|
|
||||||
|
### POST /auth/login
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": "xn001624."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应** (200):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "登录成功",
|
||||||
|
"token": "xn001624."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败响应** (200):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "API密钥无效"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 使用 cURL 测试登录接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"api_key": "xn001624."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端 API 调用示例
|
||||||
|
|
||||||
|
登录后,所有 API 请求会自动携带密钥:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 登录
|
||||||
|
const res = await axios.post('/auth/login', {
|
||||||
|
api_key: 'xn001624.'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存 token
|
||||||
|
localStorage.setItem('rustfs_token', res.data.token);
|
||||||
|
|
||||||
|
// 后续请求自动携带密钥
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: window.location.origin,
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': localStorage.getItem('rustfs_token')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 调用 API
|
||||||
|
const buckets = await api.get('/buckets');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/
|
||||||
|
├── api/
|
||||||
|
│ ├── endpoints/
|
||||||
|
│ │ └── auth_endpoints.go # 认证端点
|
||||||
|
│ └── handlers/
|
||||||
|
│ └── auth_handlers.go # 登录处理器
|
||||||
|
└── middleware/
|
||||||
|
└── auth.go # API 授权中间件
|
||||||
|
|
||||||
|
web/
|
||||||
|
├── login.html # 登录页面
|
||||||
|
└── index.html # 主页面(含登录检查)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### v1.2
|
||||||
|
- ✨ 添加 Web 登录功能
|
||||||
|
- ✨ 创建登录页面 UI
|
||||||
|
- ✨ 实现 CQRS 模式的登录验证
|
||||||
|
- 🔒 所有 Web 操作需要先登录
|
||||||
|
- 📝 添加登录文档
|
||||||
45
internal/api/endpoints/auth_endpoints.go
Normal file
45
internal/api/endpoints/auth_endpoints.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"file-system/internal/api/handlers"
|
||||||
|
"file-system/internal/infrastructure/mediator"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthEndpoint 认证端点
|
||||||
|
type AuthEndpoint struct {
|
||||||
|
mediator *mediator.Mediator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthEndpoint 创建认证端点
|
||||||
|
func NewAuthEndpoint(m *mediator.Mediator) *AuthEndpoint {
|
||||||
|
return &AuthEndpoint{
|
||||||
|
mediator: m,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 用户登录
|
||||||
|
// @Summary 用户登录
|
||||||
|
// @Description 使用 API 密钥登录
|
||||||
|
// @Tags 认证
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body handlers.LoginQuery true "登录信息"
|
||||||
|
// @Success 200 {object} handlers.LoginResult
|
||||||
|
// @Router /auth/login [post]
|
||||||
|
func (e *AuthEndpoint) Login(c *gin.Context) {
|
||||||
|
var query handlers.LoginQuery
|
||||||
|
if err := c.ShouldBindJSON(&query); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "请求参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := mediator.Send[handlers.LoginQuery, handlers.LoginResult](e.mediator, c.Request.Context(), query)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, result)
|
||||||
|
}
|
||||||
45
internal/api/handlers/auth_handlers.go
Normal file
45
internal/api/handlers/auth_handlers.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginQuery 登录查询
|
||||||
|
type LoginQuery struct {
|
||||||
|
APIKey string `json:"api_key" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResult 登录结果
|
||||||
|
type LoginResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginHandler 登录处理器
|
||||||
|
type LoginHandler struct {
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoginHandler 创建登录处理器
|
||||||
|
func NewLoginHandler(apiKey string) *LoginHandler {
|
||||||
|
return &LoginHandler{
|
||||||
|
apiKey: apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 处理登录查询
|
||||||
|
func (h *LoginHandler) Handle(ctx context.Context, query LoginQuery) (LoginResult, error) {
|
||||||
|
if query.APIKey == h.apiKey {
|
||||||
|
return LoginResult{
|
||||||
|
Success: true,
|
||||||
|
Message: "登录成功",
|
||||||
|
Token: query.APIKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoginResult{
|
||||||
|
Success: false,
|
||||||
|
Message: "API密钥无效",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
506
web/index.html
506
web/index.html
@ -26,196 +26,214 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" class="container py-4" v-cloak>
|
<div id="app" class="container py-4" v-cloak>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<!-- 未登录状态 -->
|
||||||
<h1><i class="fas fa-cloud-upload-alt text-primary me-2"></i>RustFS 文件管理系统</h1>
|
<div v-if="!isAuthenticated" class="text-center py-5">
|
||||||
<div>
|
<div class="alert alert-warning d-inline-block">
|
||||||
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
|
<i class="fas fa-lock fa-3x mb-3 d-block"></i>
|
||||||
<i class="fas fa-book me-1"></i>API 文档
|
<h4>需要登录</h4>
|
||||||
|
<p class="mb-3">请先登录以访问文件管理系统</p>
|
||||||
|
<a href="/web/login.html" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fas fa-sign-in-alt me-2"></i>前往登录
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-primary" @click="showCreateBucketModal = true">
|
|
||||||
<i class="fas fa-plus me-1"></i>新建存储桶
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<!-- 已登录状态 -->
|
||||||
<!-- Sidebar: Buckets -->
|
<div v-else>
|
||||||
<div class="col-md-3">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div class="card">
|
<h1><i class="fas fa-cloud-upload-alt text-primary me-2"></i>RustFS 文件管理系统</h1>
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div>
|
||||||
存储桶列表
|
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
|
||||||
<button class="btn btn-sm btn-link" @click="loadBuckets"><i class="fas fa-sync-alt"></i></button>
|
<i class="fas fa-book me-1"></i>API 文档
|
||||||
</div>
|
</a>
|
||||||
<div class="list-group list-group-flush">
|
<button class="btn btn-primary me-2" @click="showCreateBucketModal = true">
|
||||||
<div v-for="bucket in buckets" :key="bucket"
|
<i class="fas fa-plus me-1"></i>新建存储桶
|
||||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
</button>
|
||||||
:class="{ active: currentBucket === bucket }">
|
<button class="btn btn-outline-danger" @click="logout">
|
||||||
<a href="#" class="text-decoration-none flex-grow-1" :class="{ 'text-white': currentBucket === bucket }" @click.prevent="selectBucket(bucket)">
|
<i class="fas fa-sign-out-alt me-1"></i>退出
|
||||||
<i class="fas fa-box me-2"></i>{{ bucket }}
|
</button>
|
||||||
</a>
|
|
||||||
<span class="action-btn" :class="currentBucket === bucket ? 'text-white' : 'text-danger'" @click.stop="deleteBucket(bucket)" title="删除存储桶">
|
|
||||||
<i class="fas fa-trash-alt"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="buckets.length === 0" class="list-group-item text-muted text-center py-4">
|
|
||||||
暂无存储桶
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content: File List & Upload -->
|
<div class="row">
|
||||||
<div class="col-md-9">
|
<!-- Sidebar: Buckets -->
|
||||||
<!-- Toolbar -->
|
<div class="col-md-3">
|
||||||
<div class="card mb-3" v-if="currentBucket">
|
<div class="card">
|
||||||
<div class="card-body py-3">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div class="row g-3 align-items-center">
|
存储桶列表
|
||||||
<div class="col-auto">
|
<button class="btn btn-sm btn-link" @click="loadBuckets"><i class="fas fa-sync-alt"></i></button>
|
||||||
<label class="col-form-label fw-bold">{{ currentBucket }}</label>
|
</div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<div v-for="bucket in buckets" :key="bucket"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||||
|
:class="{ active: currentBucket === bucket }">
|
||||||
|
<a href="#" class="text-decoration-none flex-grow-1" :class="{ 'text-white': currentBucket === bucket }" @click.prevent="selectBucket(bucket)">
|
||||||
|
<i class="fas fa-box me-2"></i>{{ bucket }}
|
||||||
|
</a>
|
||||||
|
<span class="action-btn" :class="currentBucket === bucket ? 'text-white' : 'text-danger'" @click.stop="deleteBucket(bucket)" title="删除存储桶">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div v-if="buckets.length === 0" class="list-group-item text-muted text-center py-4">
|
||||||
<div class="input-group">
|
暂无存储桶
|
||||||
<span class="input-group-text bg-white"><i class="fas fa-search"></i></span>
|
</div>
|
||||||
<input type="text" class="form-control" placeholder="搜索文件名..." v-model="filters.prefix" @keyup.enter="refreshFiles">
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content: File List & Upload -->
|
||||||
|
<div class="col-md-9">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="card mb-3" v-if="currentBucket">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="row g-3 align-items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="col-form-label fw-bold">{{ currentBucket }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-white"><i class="fas fa-search"></i></span>
|
||||||
|
<input type="text" class="form-control" placeholder="搜索文件名..." v-model="filters.prefix" @keyup.enter="refreshFiles">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-success" @click="triggerFileInput">
|
||||||
|
<i class="fas fa-upload me-1"></i>上传文件
|
||||||
|
</button>
|
||||||
|
<input type="file" ref="fileInput" class="d-none" @change="handleFileSelect">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
|
||||||
<button class="btn btn-success" @click="triggerFileInput">
|
|
||||||
<i class="fas fa-upload me-1"></i>上传文件
|
|
||||||
</button>
|
|
||||||
<input type="file" ref="fileInput" class="d-none" @change="handleFileSelect">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File List Table -->
|
<!-- File List Table -->
|
||||||
<div class="card" v-if="currentBucket">
|
<div class="card" v-if="currentBucket">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<table class="table table-hover mb-0 align-middle">
|
<table class="table table-hover mb-0 align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-4">文件名</th>
|
<th class="ps-4">文件名</th>
|
||||||
<th>大小</th>
|
<th>大小</th>
|
||||||
<th>修改时间</th>
|
<th>修改时间</th>
|
||||||
<th class="text-end pe-4">操作</th>
|
<th class="text-end pe-4">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="file in files" :key="file.Key">
|
<tr v-for="file in files" :key="file.Key">
|
||||||
<td class="ps-4">
|
<td class="ps-4">
|
||||||
<i :class="getFileIcon(file.Key)" class="file-icon"></i>
|
<i :class="getFileIcon(file.Key)" class="file-icon"></i>
|
||||||
{{ file.Key }}
|
{{ file.Key }}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatSize(file.Size) }}</td>
|
<td>{{ formatSize(file.Size) }}</td>
|
||||||
<td class="text-muted small">{{ formatDate(file.LastModified) }}</td>
|
<td class="text-muted small">{{ formatDate(file.LastModified) }}</td>
|
||||||
<td class="text-end pe-4">
|
<td class="text-end pe-4">
|
||||||
<span class="action-btn text-primary" @click="previewFile(file.Key)" title="预览">
|
<span class="action-btn text-primary" @click="previewFile(file.Key)" title="预览">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="action-btn text-success" @click="downloadFile(file.Key)" title="下载">
|
<span class="action-btn text-success" @click="downloadFile(file.Key)" title="下载">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="action-btn text-danger" @click="deleteFile(file.Key)" title="删除">
|
<span class="action-btn text-danger" @click="deleteFile(file.Key)" title="删除">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="files.length === 0 && !loadingFiles">
|
<tr v-if="files.length === 0 && !loadingFiles">
|
||||||
<td colspan="4" class="text-center py-5 text-muted">
|
<td colspan="4" class="text-center py-5 text-muted">
|
||||||
<i class="fas fa-folder-open fa-3x mb-3 d-block opacity-50"></i>
|
<i class="fas fa-folder-open fa-3x mb-3 d-block opacity-50"></i>
|
||||||
暂无文件
|
暂无文件
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center" v-if="nextToken || pageHistory.length > 0">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="pageHistory.length === 0" @click="prevPage">
|
||||||
|
<i class="fas fa-chevron-left me-1"></i>上一页
|
||||||
|
</button>
|
||||||
|
<span class="text-muted small">当前页数: {{ pageHistory.length + 1 }}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" :disabled="!nextToken" @click="nextPage">
|
||||||
|
下一页<i class="fas fa-chevron-right ms-1"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pagination -->
|
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center" v-if="nextToken || pageHistory.length > 0">
|
<div v-else class="text-center py-5">
|
||||||
<button class="btn btn-sm btn-outline-secondary" :disabled="pageHistory.length === 0" @click="prevPage">
|
<i class="fas fa-arrow-left fa-3x text-muted mb-3 d-block opacity-25"></i>
|
||||||
<i class="fas fa-chevron-left me-1"></i>上一页
|
<h4 class="text-muted">请选择一个存储桶</h4>
|
||||||
</button>
|
|
||||||
<span class="text-muted small">当前页数: {{ pageHistory.length + 1 }}</span>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" :disabled="!nextToken" @click="nextPage">
|
|
||||||
下一页<i class="fas fa-chevron-right ms-1"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="text-center py-5">
|
<!-- Upload Progress -->
|
||||||
<i class="fas fa-arrow-left fa-3x text-muted mb-3 d-block opacity-25"></i>
|
<div class="card mt-3" v-if="uploads.length > 0">
|
||||||
<h4 class="text-muted">请选择一个存储桶</h4>
|
<div class="card-header">上传任务队列</div>
|
||||||
</div>
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item" v-for="upload in uploads" :key="upload.id">
|
||||||
<!-- Upload Progress -->
|
<div class="d-flex justify-content-between mb-1">
|
||||||
<div class="card mt-3" v-if="uploads.length > 0">
|
<div>
|
||||||
<div class="card-header">上传任务队列</div>
|
<span class="fw-bold">{{ upload.file.name }}</span>
|
||||||
<ul class="list-group list-group-flush">
|
<span class="badge bg-secondary ms-2">{{ upload.status }}</span>
|
||||||
<li class="list-group-item" v-for="upload in uploads" :key="upload.id">
|
</div>
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<span class="small text-muted">{{ upload.progress }}%</span>
|
||||||
<div>
|
|
||||||
<span class="fw-bold">{{ upload.file.name }}</span>
|
|
||||||
<span class="badge bg-secondary ms-2">{{ upload.status }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="small text-muted">{{ upload.progress }}%</span>
|
<div class="progress">
|
||||||
</div>
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||||
<div class="progress">
|
:class="getProgressBarClass(upload.status)"
|
||||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
role="progressbar"
|
||||||
:class="getProgressBarClass(upload.status)"
|
:style="{ width: upload.progress + '%' }"></div>
|
||||||
role="progressbar"
|
</div>
|
||||||
:style="{ width: upload.progress + '%' }"></div>
|
<div class="mt-1 small text-danger" v-if="upload.error">{{ upload.error }}</div>
|
||||||
</div>
|
</li>
|
||||||
<div class="mt-1 small text-danger" v-if="upload.error">{{ upload.error }}</div>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create Bucket Modal -->
|
<!-- Create Bucket Modal -->
|
||||||
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="showCreateBucketModal">
|
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="showCreateBucketModal">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">新建存储桶</h5>
|
<h5 class="modal-title">新建存储桶</h5>
|
||||||
<button type="button" class="btn-close" @click="showCreateBucketModal = false"></button>
|
<button type="button" class="btn-close" @click="showCreateBucketModal = false"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">存储桶名称</label>
|
<label class="form-label">存储桶名称</label>
|
||||||
<input type="text" class="form-control" v-model="newBucketName" placeholder="输入名称...">
|
<input type="text" class="form-control" v-model="newBucketName" placeholder="输入名称...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showCreateBucketModal = false">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="createBucket">创建</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showCreateBucketModal = false">取消</button>
|
</div>
|
||||||
<button type="button" class="btn btn-primary" @click="createBucket">创建</button>
|
|
||||||
|
<!-- Preview Modal -->
|
||||||
|
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="previewUrl">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">文件预览</h5>
|
||||||
|
<button type="button" class="btn-close" @click="previewUrl = null"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center bg-light p-4">
|
||||||
|
<img v-if="isPreviewImage" :src="previewUrl" class="img-fluid" style="max-height: 80vh">
|
||||||
|
<video v-else-if="isPreviewVideo" :src="previewUrl" controls class="w-100" style="max-height: 80vh"></video>
|
||||||
|
<iframe v-else :src="previewUrl" class="w-100" style="height: 60vh; border:none"></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer justify-content-center">
|
||||||
|
<a :href="previewUrl" target="_blank" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>在新窗口打开
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Preview Modal -->
|
|
||||||
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="previewUrl">
|
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">文件预览</h5>
|
|
||||||
<button type="button" class="btn-close" @click="previewUrl = null"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body text-center bg-light p-4">
|
|
||||||
<img v-if="isPreviewImage" :src="previewUrl" class="img-fluid" style="max-height: 80vh">
|
|
||||||
<video v-else-if="isPreviewVideo" :src="previewUrl" controls class="w-100" style="max-height: 80vh"></video>
|
|
||||||
<iframe v-else :src="previewUrl" class="w-100" style="height: 60vh; border:none"></iframe>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer justify-content-center">
|
|
||||||
<a :href="previewUrl" target="_blank" class="btn btn-outline-primary">
|
|
||||||
<i class="fas fa-external-link-alt me-1"></i>在新窗口打开
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -224,24 +242,53 @@
|
|||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
setup() {
|
setup() {
|
||||||
|
const isAuthenticated = ref(false);
|
||||||
const buckets = ref([]);
|
const buckets = ref([]);
|
||||||
const currentBucket = ref(null);
|
const currentBucket = ref(null);
|
||||||
const files = ref([]);
|
const files = ref([]);
|
||||||
const loadingFiles = ref(false);
|
const loadingFiles = ref(false);
|
||||||
const nextToken = ref(null);
|
const nextToken = ref(null);
|
||||||
const pageHistory = ref([]); // Stack to store tokens for previous pages
|
const pageHistory = ref([]);
|
||||||
const filters = ref({ prefix: '', maxKeys: 20 });
|
const filters = ref({ prefix: '', maxKeys: 20 });
|
||||||
|
|
||||||
const showCreateBucketModal = ref(false);
|
const showCreateBucketModal = ref(false);
|
||||||
const newBucketName = ref('');
|
const newBucketName = ref('');
|
||||||
|
|
||||||
const uploads = ref([]); // { id, file, progress, status: 'pending'|'uploading'|'completed'|'failed', error, uploadId, parts }
|
const uploads = ref([]);
|
||||||
|
|
||||||
const previewUrl = ref(null);
|
const previewUrl = ref(null);
|
||||||
const previewType = ref('');
|
const previewType = ref('');
|
||||||
|
|
||||||
// API Client
|
// 检查登录状态
|
||||||
const api = axios.create({ baseURL: window.location.origin });
|
const checkAuth = () => {
|
||||||
|
const token = localStorage.getItem('rustfs_token');
|
||||||
|
if (token) {
|
||||||
|
isAuthenticated.value = true;
|
||||||
|
initApiClient(token);
|
||||||
|
} else {
|
||||||
|
isAuthenticated.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化 API 客户端
|
||||||
|
let api = null;
|
||||||
|
|
||||||
|
const initApiClient = (token) => {
|
||||||
|
api = axios.create({
|
||||||
|
baseURL: window.location.origin,
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
const logout = () => {
|
||||||
|
if (confirm('确定要退出登录吗?')) {
|
||||||
|
localStorage.removeItem('rustfs_token');
|
||||||
|
window.location.href = '/web/login.html';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Load Buckets
|
// Load Buckets
|
||||||
const loadBuckets = async () => {
|
const loadBuckets = async () => {
|
||||||
@ -249,7 +296,13 @@
|
|||||||
const res = await api.get('/buckets');
|
const res = await api.get('/buckets');
|
||||||
buckets.value = res.data.buckets || [];
|
buckets.value = res.data.buckets || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('加载存储桶失败: ' + (err.response?.data?.error || err.message));
|
if (err.response?.status === 401) {
|
||||||
|
alert('登录已过期,请重新登录');
|
||||||
|
localStorage.removeItem('rustfs_token');
|
||||||
|
window.location.href = '/web/login.html';
|
||||||
|
} else {
|
||||||
|
alert('加载存储桶失败: ' + (err.response?.data?.error || err.message));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -275,70 +328,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Load Files
|
// Load Files
|
||||||
const loadFiles = async (token = null) => {
|
const currentToken = ref(null);
|
||||||
loadingFiles.value = true;
|
|
||||||
try {
|
|
||||||
const res = await api.get('/files/list', {
|
|
||||||
params: {
|
|
||||||
bucket_name: currentBucket.value,
|
|
||||||
prefix: filters.value.prefix,
|
|
||||||
max_keys: filters.value.maxKeys,
|
|
||||||
token: token
|
|
||||||
}
|
|
||||||
});
|
|
||||||
files.value = res.data.Files || [];
|
|
||||||
nextToken.value = res.data.NextContinuationToken;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert('加载文件列表失败');
|
|
||||||
} finally {
|
|
||||||
loadingFiles.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshFiles = () => {
|
|
||||||
nextToken.value = null;
|
|
||||||
pageHistory.value = [];
|
|
||||||
loadFiles();
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextPage = () => {
|
|
||||||
if (nextToken.value) {
|
|
||||||
pageHistory.value.push(filters.value.token); // Save current token (which was used to get here) - actually we need to save the *previous* state.
|
|
||||||
// Simplified: pageHistory stores the token that GENERATED the current page.
|
|
||||||
// Actually better:
|
|
||||||
// Page 1: token=null. Res returns T1.
|
|
||||||
// Page 2: req token=T1. Res returns T2.
|
|
||||||
// History: [null, T1]
|
|
||||||
pageHistory.value.push(nextToken.value); // Wait, this logic is tricky without full state.
|
|
||||||
// Correct logic: push current page's start token to history.
|
|
||||||
// But wait, listObjectsV2 is stateless.
|
|
||||||
// Let's just reload with nextToken.
|
|
||||||
// We need to store the token used to fetch CURRENT page to go back? No, to go back we need token of PREVIOUS page.
|
|
||||||
// Let's simplistic approach: History stores [token_for_page_1, token_for_page_2...]
|
|
||||||
// But we don't know token for page 1 (it's null).
|
|
||||||
// Let's ignore complex history for now and just support Next, and reset on bucket change.
|
|
||||||
// To support Prev properly, we need to push the token used for *current* view into stack before moving.
|
|
||||||
// But we don't have "current token" stored in variable clearly except implicitly.
|
|
||||||
// Let's re-implement: loadFiles takes token.
|
|
||||||
|
|
||||||
// We will just use the returned nextToken for next page.
|
|
||||||
// For prev page, we need a stack of tokens.
|
|
||||||
loadFiles(nextToken.value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Fix pagination logic later or keep simple "Load More" style?
|
|
||||||
// User asked for "Pagination", table style usually implies Next/Prev.
|
|
||||||
// S3 only supports forward paging efficiently. Prev requires caching tokens.
|
|
||||||
// We will implement simple Next for now, Prev is hard without state.
|
|
||||||
// Let's actually implement a stack.
|
|
||||||
// When clicking Next: push current_token (or null for page 1) to stack. load(next_token).
|
|
||||||
|
|
||||||
// Refined Pagination
|
|
||||||
const currentToken = ref(null); // Token used for current page
|
|
||||||
|
|
||||||
const loadFilesWrapped = async (token) => {
|
const loadFilesWrapped = async (token) => {
|
||||||
loadingFiles.value = true;
|
loadingFiles.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/files/list', {
|
const res = await api.get('/files/list', {
|
||||||
params: {
|
params: {
|
||||||
@ -357,32 +350,35 @@
|
|||||||
loadingFiles.value = false;
|
loadingFiles.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadFiles = () => loadFilesWrapped(null);
|
||||||
|
|
||||||
|
const refreshFiles = () => {
|
||||||
|
nextToken.value = null;
|
||||||
|
pageHistory.value = [];
|
||||||
|
loadFiles();
|
||||||
|
};
|
||||||
|
|
||||||
const nextP = () => {
|
const nextP = () => {
|
||||||
if(!nextToken.value) return;
|
if(!nextToken.value) return;
|
||||||
pageHistory.value.push(currentToken.value);
|
pageHistory.value.push(currentToken.value);
|
||||||
loadFilesWrapped(nextToken.value);
|
loadFilesWrapped(nextToken.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevPage = () => {
|
const prevPage = () => {
|
||||||
if(pageHistory.value.length === 0) return;
|
if(pageHistory.value.length === 0) return;
|
||||||
const prevToken = pageHistory.value.pop();
|
const prevToken = pageHistory.value.pop();
|
||||||
loadFilesWrapped(prevToken);
|
loadFilesWrapped(prevToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override originals
|
|
||||||
const loadFilesInitial = () => loadFilesWrapped(null);
|
|
||||||
|
|
||||||
|
|
||||||
// File Upload
|
// File Upload
|
||||||
const triggerFileInput = () => document.querySelector('input[type=file]').click();
|
const triggerFileInput = () => document.querySelector('input[type=file]').click();
|
||||||
|
|
||||||
const handleFileSelect = async (e) => {
|
const handleFileSelect = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
e.target.value = ''; // reset
|
e.target.value = '';
|
||||||
|
|
||||||
// Create upload task
|
|
||||||
const uploadTask = {
|
const uploadTask = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
file: file,
|
file: file,
|
||||||
@ -393,7 +389,7 @@
|
|||||||
parts: []
|
parts: []
|
||||||
};
|
};
|
||||||
uploads.value.unshift(uploadTask);
|
uploads.value.unshift(uploadTask);
|
||||||
|
|
||||||
processUpload(uploadTask);
|
processUpload(uploadTask);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -404,17 +400,15 @@
|
|||||||
const key = file.name;
|
const key = file.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Init Multipart
|
|
||||||
const initRes = await api.post('/files/multipart/init', {
|
const initRes = await api.post('/files/multipart/init', {
|
||||||
bucket_name: bucket,
|
bucket_name: bucket,
|
||||||
object_key: key
|
object_key: key
|
||||||
});
|
});
|
||||||
task.uploadId = initRes.data.upload_id;
|
task.uploadId = initRes.data.upload_id;
|
||||||
|
|
||||||
// 2. Upload Parts
|
|
||||||
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
|
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
for (let i = 0; i < totalParts; i++) {
|
for (let i = 0; i < totalParts; i++) {
|
||||||
const start = i * CHUNK_SIZE;
|
const start = i * CHUNK_SIZE;
|
||||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||||
@ -428,7 +422,6 @@
|
|||||||
formData.append('part_number', partNumber);
|
formData.append('part_number', partNumber);
|
||||||
formData.append('file', chunk);
|
formData.append('file', chunk);
|
||||||
|
|
||||||
// Retry logic for part
|
|
||||||
let retries = 3;
|
let retries = 3;
|
||||||
let etag = null;
|
let etag = null;
|
||||||
while(retries > 0) {
|
while(retries > 0) {
|
||||||
@ -442,14 +435,11 @@
|
|||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push({ PartNumber: partNumber, ETag: etag });
|
parts.push({ PartNumber: partNumber, ETag: etag });
|
||||||
|
|
||||||
// Update Progress
|
|
||||||
task.progress = Math.round(((i + 1) / totalParts) * 100);
|
task.progress = Math.round(((i + 1) / totalParts) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Complete
|
|
||||||
await api.post('/files/multipart/complete', {
|
await api.post('/files/multipart/complete', {
|
||||||
bucket_name: bucket,
|
bucket_name: bucket,
|
||||||
object_key: key,
|
object_key: key,
|
||||||
@ -475,8 +465,7 @@
|
|||||||
params: { bucket_name: currentBucket.value, object_key: key }
|
params: { bucket_name: currentBucket.value, object_key: key }
|
||||||
});
|
});
|
||||||
previewUrl.value = res.data.url;
|
previewUrl.value = res.data.url;
|
||||||
|
|
||||||
// Determine type
|
|
||||||
const ext = key.split('.').pop().toLowerCase();
|
const ext = key.split('.').pop().toLowerCase();
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
|
||||||
previewType.value = 'image';
|
previewType.value = 'image';
|
||||||
@ -489,7 +478,7 @@
|
|||||||
alert('无法获取预览链接');
|
alert('无法获取预览链接');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPreviewImage = computed(() => previewType.value === 'image');
|
const isPreviewImage = computed(() => previewType.value === 'image');
|
||||||
const isPreviewVideo = computed(() => previewType.value === 'video');
|
const isPreviewVideo = computed(() => previewType.value === 'video');
|
||||||
|
|
||||||
@ -550,7 +539,7 @@
|
|||||||
if (['zip', 'rar'].includes(ext)) return 'fas fa-file-archive text-warning';
|
if (['zip', 'rar'].includes(ext)) return 'fas fa-file-archive text-warning';
|
||||||
return 'fas fa-file text-secondary';
|
return 'fas fa-file text-secondary';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProgressBarClass = (status) => {
|
const getProgressBarClass = (status) => {
|
||||||
if(status === 'completed') return 'bg-success';
|
if(status === 'completed') return 'bg-success';
|
||||||
if(status === 'failed') return 'bg-danger';
|
if(status === 'failed') return 'bg-danger';
|
||||||
@ -558,13 +547,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadBuckets();
|
checkAuth();
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
loadBuckets();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isAuthenticated,
|
||||||
buckets, currentBucket, files, loadingFiles, nextToken, pageHistory, filters,
|
buckets, currentBucket, files, loadingFiles, nextToken, pageHistory, filters,
|
||||||
showCreateBucketModal, newBucketName, uploads, previewUrl, isPreviewImage, isPreviewVideo,
|
showCreateBucketModal, newBucketName, uploads, previewUrl, isPreviewImage, isPreviewVideo,
|
||||||
loadBuckets, createBucket, selectBucket, deleteBucket, refreshFiles: () => loadFilesInitial(),
|
logout,
|
||||||
|
loadBuckets, createBucket, selectBucket, deleteBucket, refreshFiles,
|
||||||
nextPage: nextP, prevPage,
|
nextPage: nextP, prevPage,
|
||||||
triggerFileInput, handleFileSelect,
|
triggerFileInput, handleFileSelect,
|
||||||
previewFile, downloadFile, deleteFile,
|
previewFile, downloadFile, deleteFile,
|
||||||
|
|||||||
212
web/login.html
Normal file
212
web/login.html
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>RustFS - 用户登录</title>
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.login-header i {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.login-header h2 {
|
||||||
|
color: #333;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.login-header p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||||
|
}
|
||||||
|
.input-group-text {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 10px 0 0 10px;
|
||||||
|
}
|
||||||
|
.input-group .form-control {
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0 10px 10px 0;
|
||||||
|
}
|
||||||
|
.input-group .form-control:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
.btn-login {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.btn-login:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-login:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
border-radius: 10px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 25px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" class="container" v-cloak>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<i class="fas fa-cloud"></i>
|
||||||
|
<h2>RustFS 文件管理</h2>
|
||||||
|
<p>请输入 API 密钥以访问系统</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-danger" role="alert" :style="{ display: showError ? 'block' : 'none' }">
|
||||||
|
<i class="fas fa-exclamation-circle me-2"></i>{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="login">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">API 密钥</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="请输入 API 密钥"
|
||||||
|
v-model="apiKey"
|
||||||
|
:disabled="loading"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-text text-muted mt-2">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>
|
||||||
|
密钥格式: xn001624.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-login" :disabled="loading">
|
||||||
|
<span v-if="loading">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
登录中...
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<i class="fas fa-sign-in-alt me-2"></i>登 录
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p class="mb-1">Powered by RustFS & Gin</p>
|
||||||
|
<a href="/swagger/index.html" target="_blank" class="text-decoration-none">
|
||||||
|
<i class="fas fa-book me-1"></i>API 文档
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const { createApp, ref } = Vue;
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
setup() {
|
||||||
|
const apiKey = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const showError = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
if (!apiKey.value.trim()) {
|
||||||
|
showError.value = true;
|
||||||
|
errorMessage.value = '请输入 API 密钥';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
showError.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post('/auth/login', {
|
||||||
|
api_key: apiKey.value
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
// 保存 token 到 localStorage
|
||||||
|
localStorage.setItem('rustfs_token', res.data.token);
|
||||||
|
// 跳转到主页面
|
||||||
|
window.location.href = '/web/';
|
||||||
|
} else {
|
||||||
|
showError.value = true;
|
||||||
|
errorMessage.value = res.data.message || '登录失败';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError.value = true;
|
||||||
|
errorMessage.value = err.response?.data?.error || err.message || '登录请求失败';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
loading,
|
||||||
|
showError,
|
||||||
|
errorMessage,
|
||||||
|
login
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}).mount('#app');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user