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