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:
root 2026-01-05 20:27:45 +08:00
parent 98a3701d54
commit 54f66c56ed
6 changed files with 732 additions and 256 deletions

View File

@ -44,6 +44,7 @@ func main() {
uploadPartHandler := handlers.NewUploadPartHandler(s3Repo)
completeMultipartHandler := handlers.NewCompleteMultipartHandler(s3Repo)
deleteFileHandler := handlers.NewDeleteFileHandler(s3Repo)
loginHandler := handlers.NewLoginHandler(middleware.API_KEY_VALUE)
// Register Handlers
mediator.Register[handlers.UploadFileCommand, string](m, uploadHandler)
@ -58,6 +59,7 @@ func main() {
mediator.Register[handlers.UploadPartCommand, string](m, uploadPartHandler)
mediator.Register[handlers.CompleteMultipartCommand, string](m, completeMultipartHandler)
mediator.Register[handlers.DeleteFileCommand, string](m, deleteFileHandler)
mediator.Register[handlers.LoginQuery, handlers.LoginResult](m, loginHandler)
// Validators
uploadValidator := validators.NewUploadFileValidator()
@ -68,6 +70,7 @@ func main() {
// Endpoints
fileEndpoint := endpoints.NewFileEndpoint(m, uploadValidator, downloadValidator, newFeaturesValidator)
bucketEndpoint := endpoints.NewBucketEndpoint(m, createBucketValidator)
authEndpoint := endpoints.NewAuthEndpoint(m)
// Router
r := gin.Default()
@ -85,6 +88,9 @@ func main() {
c.Next()
})
// 公开接口(无需授权)
r.POST("/auth/login", authEndpoint.Login)
// API授权中间件组
api := r.Group("/")
api.Use(middleware.AuthMiddleware())

174
docs/LOGIN_GUIDE.md Normal file
View 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 操作需要先登录
- 📝 添加登录文档

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

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

View File

@ -26,196 +26,214 @@
</head>
<body>
<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>
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
<i class="fas fa-book me-1"></i>API 文档
<!-- 未登录状态 -->
<div v-if="!isAuthenticated" class="text-center py-5">
<div class="alert alert-warning d-inline-block">
<i class="fas fa-lock fa-3x mb-3 d-block"></i>
<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>
<button class="btn btn-primary" @click="showCreateBucketModal = true">
<i class="fas fa-plus me-1"></i>新建存储桶
</button>
</div>
</div>
<div class="row">
<!-- Sidebar: Buckets -->
<div class="col-md-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
存储桶列表
<button class="btn btn-sm btn-link" @click="loadBuckets"><i class="fas fa-sync-alt"></i></button>
</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 v-if="buckets.length === 0" class="list-group-item text-muted text-center py-4">
暂无存储桶
</div>
</div>
<!-- 已登录状态 -->
<div v-else>
<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>
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
<i class="fas fa-book me-1"></i>API 文档
</a>
<button class="btn btn-primary me-2" @click="showCreateBucketModal = true">
<i class="fas fa-plus me-1"></i>新建存储桶
</button>
<button class="btn btn-outline-danger" @click="logout">
<i class="fas fa-sign-out-alt me-1"></i>退出
</button>
</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 class="row">
<!-- Sidebar: Buckets -->
<div class="col-md-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
存储桶列表
<button class="btn btn-sm btn-link" @click="loadBuckets"><i class="fas fa-sync-alt"></i></button>
</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 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 v-if="buckets.length === 0" class="list-group-item text-muted text-center py-4">
暂无存储桶
</div>
</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 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>
<!-- File List Table -->
<div class="card" v-if="currentBucket">
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-4">文件名</th>
<th>大小</th>
<th>修改时间</th>
<th class="text-end pe-4">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.Key">
<td class="ps-4">
<i :class="getFileIcon(file.Key)" class="file-icon"></i>
{{ file.Key }}
</td>
<td>{{ formatSize(file.Size) }}</td>
<td class="text-muted small">{{ formatDate(file.LastModified) }}</td>
<td class="text-end pe-4">
<span class="action-btn text-primary" @click="previewFile(file.Key)" title="预览">
<i class="fas fa-eye"></i>
</span>
<span class="action-btn text-success" @click="downloadFile(file.Key)" title="下载">
<i class="fas fa-download"></i>
</span>
<span class="action-btn text-danger" @click="deleteFile(file.Key)" title="删除">
<i class="fas fa-trash-alt"></i>
</span>
</td>
</tr>
<tr v-if="files.length === 0 && !loadingFiles">
<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>
暂无文件
</td>
</tr>
</tbody>
</table>
<!-- File List Table -->
<div class="card" v-if="currentBucket">
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-4">文件名</th>
<th>大小</th>
<th>修改时间</th>
<th class="text-end pe-4">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.Key">
<td class="ps-4">
<i :class="getFileIcon(file.Key)" class="file-icon"></i>
{{ file.Key }}
</td>
<td>{{ formatSize(file.Size) }}</td>
<td class="text-muted small">{{ formatDate(file.LastModified) }}</td>
<td class="text-end pe-4">
<span class="action-btn text-primary" @click="previewFile(file.Key)" title="预览">
<i class="fas fa-eye"></i>
</span>
<span class="action-btn text-success" @click="downloadFile(file.Key)" title="下载">
<i class="fas fa-download"></i>
</span>
<span class="action-btn text-danger" @click="deleteFile(file.Key)" title="删除">
<i class="fas fa-trash-alt"></i>
</span>
</td>
</tr>
<tr v-if="files.length === 0 && !loadingFiles">
<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>
暂无文件
</td>
</tr>
</tbody>
</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>
<!-- 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 v-else class="text-center py-5">
<i class="fas fa-arrow-left fa-3x text-muted mb-3 d-block opacity-25"></i>
<h4 class="text-muted">请选择一个存储桶</h4>
</div>
</div>
<div v-else class="text-center py-5">
<i class="fas fa-arrow-left fa-3x text-muted mb-3 d-block opacity-25"></i>
<h4 class="text-muted">请选择一个存储桶</h4>
</div>
<!-- Upload Progress -->
<div class="card mt-3" v-if="uploads.length > 0">
<div class="card-header">上传任务队列</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" v-for="upload in uploads" :key="upload.id">
<div class="d-flex justify-content-between mb-1">
<div>
<span class="fw-bold">{{ upload.file.name }}</span>
<span class="badge bg-secondary ms-2">{{ upload.status }}</span>
<!-- Upload Progress -->
<div class="card mt-3" v-if="uploads.length > 0">
<div class="card-header">上传任务队列</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" v-for="upload in uploads" :key="upload.id">
<div class="d-flex justify-content-between mb-1">
<div>
<span class="fw-bold">{{ upload.file.name }}</span>
<span class="badge bg-secondary ms-2">{{ upload.status }}</span>
</div>
<span class="small text-muted">{{ upload.progress }}%</span>
</div>
<span class="small text-muted">{{ upload.progress }}%</span>
</div>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
:class="getProgressBarClass(upload.status)"
role="progressbar"
:style="{ width: upload.progress + '%' }"></div>
</div>
<div class="mt-1 small text-danger" v-if="upload.error">{{ upload.error }}</div>
</li>
</ul>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
:class="getProgressBarClass(upload.status)"
role="progressbar"
:style="{ width: upload.progress + '%' }"></div>
</div>
<div class="mt-1 small text-danger" v-if="upload.error">{{ upload.error }}</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Create Bucket Modal -->
<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-content">
<div class="modal-header">
<h5 class="modal-title">新建存储桶</h5>
<button type="button" class="btn-close" @click="showCreateBucketModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">存储桶名称</label>
<input type="text" class="form-control" v-model="newBucketName" placeholder="输入名称...">
<!-- Create Bucket Modal -->
<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-content">
<div class="modal-header">
<h5 class="modal-title">新建存储桶</h5>
<button type="button" class="btn-close" @click="showCreateBucketModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">存储桶名称</label>
<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 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>
<!-- 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>
<!-- 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>
<script>
@ -224,24 +242,53 @@
createApp({
setup() {
const isAuthenticated = ref(false);
const buckets = ref([]);
const currentBucket = ref(null);
const files = ref([]);
const loadingFiles = ref(false);
const nextToken = ref(null);
const pageHistory = ref([]); // Stack to store tokens for previous pages
const pageHistory = ref([]);
const filters = ref({ prefix: '', maxKeys: 20 });
const showCreateBucketModal = ref(false);
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 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
const loadBuckets = async () => {
@ -249,7 +296,13 @@
const res = await api.get('/buckets');
buckets.value = res.data.buckets || [];
} 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
const loadFiles = async (token = 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 currentToken = ref(null);
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) => {
loadingFiles.value = true;
loadingFiles.value = true;
try {
const res = await api.get('/files/list', {
params: {
@ -357,32 +350,35 @@
loadingFiles.value = false;
}
}
const loadFiles = () => loadFilesWrapped(null);
const refreshFiles = () => {
nextToken.value = null;
pageHistory.value = [];
loadFiles();
};
const nextP = () => {
if(!nextToken.value) return;
pageHistory.value.push(currentToken.value);
loadFilesWrapped(nextToken.value);
}
const prevPage = () => {
if(pageHistory.value.length === 0) return;
const prevToken = pageHistory.value.pop();
loadFilesWrapped(prevToken);
}
// Override originals
const loadFilesInitial = () => loadFilesWrapped(null);
// File Upload
const triggerFileInput = () => document.querySelector('input[type=file]').click();
const handleFileSelect = async (e) => {
const file = e.target.files[0];
if (!file) return;
e.target.value = ''; // reset
e.target.value = '';
// Create upload task
const uploadTask = {
id: Date.now(),
file: file,
@ -393,7 +389,7 @@
parts: []
};
uploads.value.unshift(uploadTask);
processUpload(uploadTask);
};
@ -404,17 +400,15 @@
const key = file.name;
try {
// 1. Init Multipart
const initRes = await api.post('/files/multipart/init', {
bucket_name: bucket,
object_key: key
});
task.uploadId = initRes.data.upload_id;
// 2. Upload Parts
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
const parts = [];
for (let i = 0; i < totalParts; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
@ -428,7 +422,6 @@
formData.append('part_number', partNumber);
formData.append('file', chunk);
// Retry logic for part
let retries = 3;
let etag = null;
while(retries > 0) {
@ -442,14 +435,11 @@
await new Promise(r => setTimeout(r, 1000));
}
}
parts.push({ PartNumber: partNumber, ETag: etag });
// Update Progress
task.progress = Math.round(((i + 1) / totalParts) * 100);
}
// 3. Complete
await api.post('/files/multipart/complete', {
bucket_name: bucket,
object_key: key,
@ -475,8 +465,7 @@
params: { bucket_name: currentBucket.value, object_key: key }
});
previewUrl.value = res.data.url;
// Determine type
const ext = key.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
previewType.value = 'image';
@ -489,7 +478,7 @@
alert('无法获取预览链接');
}
};
const isPreviewImage = computed(() => previewType.value === 'image');
const isPreviewVideo = computed(() => previewType.value === 'video');
@ -550,7 +539,7 @@
if (['zip', 'rar'].includes(ext)) return 'fas fa-file-archive text-warning';
return 'fas fa-file text-secondary';
};
const getProgressBarClass = (status) => {
if(status === 'completed') return 'bg-success';
if(status === 'failed') return 'bg-danger';
@ -558,13 +547,18 @@
}
onMounted(() => {
loadBuckets();
checkAuth();
if (isAuthenticated.value) {
loadBuckets();
}
});
return {
isAuthenticated,
buckets, currentBucket, files, loadingFiles, nextToken, pageHistory, filters,
showCreateBucketModal, newBucketName, uploads, previewUrl, isPreviewImage, isPreviewVideo,
loadBuckets, createBucket, selectBucket, deleteBucket, refreshFiles: () => loadFilesInitial(),
logout,
loadBuckets, createBucket, selectBucket, deleteBucket, refreshFiles,
nextPage: nextP, prevPage,
triggerFileInput, handleFileSelect,
previewFile, downloadFile, deleteFile,

212
web/login.html Normal file
View 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>