基础功能完成并进行错误修复
This commit is contained in:
6
.env
Normal file
6
.env
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# API 基础地址
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
VITE_APP_TITLE=文件中转站
|
||||||
|
VITE_APP_DESCRIPTION=安全便捷的文件暂存服务
|
||||||
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
VITE_API_URL=/api
|
||||||
66
OPTIMIZATION.md
Normal file
66
OPTIMIZATION.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 界面优化说明
|
||||||
|
|
||||||
|
## 优化内容
|
||||||
|
|
||||||
|
### 1. 首页界面优化
|
||||||
|
- ✅ **移除顶部大标题**:简化了页面布局,专注于核心功能
|
||||||
|
- ✅ **重新设计操作逻辑**:
|
||||||
|
- 默认显示简洁的取件码输入界面
|
||||||
|
- 输入取件码后在当前页面显示文件或文本内容
|
||||||
|
- 支持文件下载、文本复制等操作,无需跳转
|
||||||
|
- 底部提供"发送文件或文本"的入口
|
||||||
|
|
||||||
|
### 2. 上传页面重构
|
||||||
|
- ✅ **统一上传界面**:将文件上传和文本保存合并到一个页面
|
||||||
|
- ✅ **Tab切换设计**:
|
||||||
|
- "文件上传" - 支持拖拽、多文件选择、进度显示
|
||||||
|
- "文本保存" - 快速输入和保存文本内容
|
||||||
|
- ✅ **完整配置选项**:
|
||||||
|
- 过期策略:按时间或按访问/下载次数
|
||||||
|
- 备注信息:为分享内容添加说明
|
||||||
|
- 实时反馈:上传进度和状态提示
|
||||||
|
|
||||||
|
### 3. 用户体验优化
|
||||||
|
- ✅ **减少页面跳转**:主要操作都在当前页面完成
|
||||||
|
- ✅ **即时反馈**:
|
||||||
|
- 取件码输入后立即显示结果
|
||||||
|
- 文件/文本上传后显示取件码
|
||||||
|
- 复制、下载等操作有即时提示
|
||||||
|
- ✅ **响应式设计**:适配不同屏幕尺寸
|
||||||
|
- ✅ **操作便捷性**:
|
||||||
|
- 支持粘贴取件码
|
||||||
|
- 支持拖拽文件
|
||||||
|
- 一键复制取件码
|
||||||
|
- 批量文件下载
|
||||||
|
|
||||||
|
### 4. 界面简洁性
|
||||||
|
- ✅ **干净的视觉设计**:去除不必要的装饰元素
|
||||||
|
- ✅ **清晰的信息层次**:重要信息突出显示
|
||||||
|
- ✅ **合理的空间布局**:最大宽度限制,避免界面过宽
|
||||||
|
- ✅ **一致的交互模式**:统一的按钮、卡片、表单风格
|
||||||
|
|
||||||
|
## 主要功能流程
|
||||||
|
|
||||||
|
### 取件流程
|
||||||
|
1. 用户访问首页
|
||||||
|
2. 输入取件码(支持粘贴)
|
||||||
|
3. 在当前页面显示文件列表或文本内容
|
||||||
|
4. 可直接下载文件或复制文本,无需跳转
|
||||||
|
|
||||||
|
### 发送流程
|
||||||
|
1. 点击"发送文件或文本"按钮
|
||||||
|
2. 选择文件上传或文本保存的Tab
|
||||||
|
3. 配置过期策略和备注
|
||||||
|
4. 提交后获得取件码,可直接复制分享
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
- Vue 3 Composition API
|
||||||
|
- TypeScript 类型安全
|
||||||
|
- Shadcn-Vue 组件库
|
||||||
|
- 响应式设计(Tailwind CSS)
|
||||||
|
- 文件拖拽上传
|
||||||
|
- 剪贴板API集成
|
||||||
|
- 进度条显示
|
||||||
|
- Toast消息提示
|
||||||
|
|
||||||
|
这次优化大大简化了用户操作流程,提升了使用体验,同时保持了界面的简洁性和功能的完整性。
|
||||||
119
README-new.md
Normal file
119
README-new.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# 文件中转站前端
|
||||||
|
|
||||||
|
一个基于 Vue 3 + TypeScript + Vite + Shadcn-Vue 构建的文件中转站前端应用。
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
文件中转站是一个极简的文件暂存服务,用户可以像使用现实中的暂存柜一样,将文件、文本等内容临时存储在网站中,并获得一个取件码用于在其他设备上提取文件。
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
### 用户功能
|
||||||
|
- **文件上传**: 支持批量上传多种格式的文件
|
||||||
|
- **文本分享**: 快速分享长文本内容
|
||||||
|
- **取件码提取**: 使用取件码获取文件或文本
|
||||||
|
- **过期策略**: 支持按时间或下载次数设置过期规则
|
||||||
|
- **快捷操作**: 一键复制、打包下载等便捷功能
|
||||||
|
|
||||||
|
### 管理功能
|
||||||
|
- **批次管理**: 查看、编辑、删除文件批次
|
||||||
|
- **API Token**: 创建和管理 API 访问凭证
|
||||||
|
- **数据统计**: 系统运行状态和使用统计
|
||||||
|
- **权限控制**: 管理员密码保护的后台系统
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**: Vue 3 + TypeScript
|
||||||
|
- **构建工具**: Vite
|
||||||
|
- **UI 组件**: Shadcn-Vue (基于 Tailwind CSS)
|
||||||
|
- **路由**: Vue Router
|
||||||
|
- **HTTP 客户端**: Axios
|
||||||
|
- **状态管理**: 组合式 API
|
||||||
|
- **代码规范**: ESLint + Prettier
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm 或 yarn 或 pnpm
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问 `http://localhost:5173`
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预览构建结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
创建环境变量文件:
|
||||||
|
|
||||||
|
### `.env` (通用配置)
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
VITE_APP_TITLE=文件中转站
|
||||||
|
VITE_APP_DESCRIPTION=安全便捷的文件暂存服务
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.env.development` (开发环境)
|
||||||
|
```env
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.env.production` (生产环境)
|
||||||
|
```env
|
||||||
|
VITE_API_URL=/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 页面路由
|
||||||
|
|
||||||
|
### 用户页面
|
||||||
|
- `/` - 首页 (取件/存件切换)
|
||||||
|
- `/upload` - 文件上传页面
|
||||||
|
- `/pickup` - 取件页面
|
||||||
|
- `/pickup/:code` - 直接取件 (URL 包含取件码)
|
||||||
|
|
||||||
|
### 管理页面 (隐藏入口)
|
||||||
|
- `/admin/login` - 管理员登录
|
||||||
|
- `/admin` - 管理概览
|
||||||
|
- `/admin/batches` - 批次管理
|
||||||
|
- `/admin/tokens` - API Token 管理
|
||||||
|
|
||||||
|
## 功能特点
|
||||||
|
|
||||||
|
### 用户体验
|
||||||
|
- **极简设计**: 首页默认取件,一键切换存件模式
|
||||||
|
- **智能识别**: 自动识别文件类型并显示对应图标
|
||||||
|
- **快捷操作**: 支持剪贴板粘贴取件码、一键复制等
|
||||||
|
- **进度反馈**: 上传进度显示和状态提示
|
||||||
|
- **响应式设计**: 完美适配桌面和移动设备
|
||||||
|
|
||||||
|
### 管理功能
|
||||||
|
- **数据统计**: 实时显示系统运行数据
|
||||||
|
- **批次管理**: 支持搜索、筛选、分页查看
|
||||||
|
- **权限控制**: Token 可设置权限范围和过期时间
|
||||||
|
- **操作日志**: 详细的操作记录和状态跟踪
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
74
REFACTOR_COMPLETE.md
Normal file
74
REFACTOR_COMPLETE.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 界面重构完成
|
||||||
|
|
||||||
|
## ✅ 完成的改进
|
||||||
|
|
||||||
|
### 1. **UploadPage.vue 重构**
|
||||||
|
- **共用配置区域**:过期策略和备注信息现在在同一个配置卡片中
|
||||||
|
- **自定义Tab样式**:
|
||||||
|
- 移除了shadcn-vue的Tabs组件,使用自定义样式
|
||||||
|
- 修复了tab指示器高度超出背景的问题
|
||||||
|
- 采用圆角背景+白色选中状态的现代设计
|
||||||
|
- **内容区域优化**:
|
||||||
|
- 文件上传和文本输入区域独立显示
|
||||||
|
- 配置区域根据内容动态显示
|
||||||
|
- 统一的操作按钮和进度显示
|
||||||
|
|
||||||
|
### 2. **SVG路径错误修复**
|
||||||
|
- 修复了控制台中的SVG `<path>` 属性错误
|
||||||
|
- 所有加载动画图标使用正确的SVG路径语法
|
||||||
|
|
||||||
|
### 3. **错误处理优化**
|
||||||
|
- **HomePage.vue 和 UploadPage.vue**:
|
||||||
|
- 404等业务异常不再输出到控制台
|
||||||
|
- 客户端错误(4xx)只显示Toast提示
|
||||||
|
- 服务器错误(5xx)和网络错误才记录到控制台
|
||||||
|
- 改善开发和生产环境的错误显示
|
||||||
|
|
||||||
|
### 4. **用户体验提升**
|
||||||
|
- **Tab切换**:
|
||||||
|
- 更平滑的过渡动画
|
||||||
|
- 清晰的视觉反馈
|
||||||
|
- 更好的视觉层次
|
||||||
|
- **配置共用**:
|
||||||
|
- 减少重复界面元素
|
||||||
|
- 统一的配置体验
|
||||||
|
- 更直观的操作流程
|
||||||
|
|
||||||
|
## 🎯 技术细节
|
||||||
|
|
||||||
|
### 自定义Tab设计
|
||||||
|
```vue
|
||||||
|
<div class="flex rounded-lg bg-gray-100 p-1 mb-8">
|
||||||
|
<button :class="activeTab === 'file' ? 'bg-white shadow-sm' : 'text-gray-500'">
|
||||||
|
文件上传
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误处理策略
|
||||||
|
```typescript
|
||||||
|
const handleUploadError = (error: any, defaultMessage: string) => {
|
||||||
|
if (error.response?.status >= 400 && error.response?.status < 500) {
|
||||||
|
// 业务异常:只显示Toast,不记录控制台
|
||||||
|
toast.error(error.response?.data?.msg || defaultMessage)
|
||||||
|
} else {
|
||||||
|
// 系统异常:记录控制台用于调试
|
||||||
|
console.error(`${defaultMessage}:`, error)
|
||||||
|
toast.error(`${defaultMessage},请重试`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 共用配置逻辑
|
||||||
|
- 文件上传和文本保存共享相同的过期策略配置
|
||||||
|
- 根据activeTab动态显示不同的标签文本
|
||||||
|
- 统一的配置验证和提交逻辑
|
||||||
|
|
||||||
|
## 🌟 界面效果
|
||||||
|
|
||||||
|
1. **更简洁的Tab设计**:圆角背景,选中状态有明显对比
|
||||||
|
2. **统一的配置体验**:所有上传配置在同一个区域
|
||||||
|
3. **清爽的错误提示**:控制台不再被业务异常污染
|
||||||
|
4. **流畅的交互体验**:加载状态、进度显示、即时反馈
|
||||||
|
|
||||||
|
界面现在更加现代化和用户友好!🎉
|
||||||
294
api/swagger.json
294
api/swagger.json
@@ -284,8 +284,8 @@
|
|||||||
"summary": "获取批次详情",
|
"summary": "获取批次详情",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"description": "批次 ID",
|
"description": "批次 ID (UUID)",
|
||||||
"name": "batch_id",
|
"name": "batch_id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -337,8 +337,8 @@
|
|||||||
"summary": "修改批次信息",
|
"summary": "修改批次信息",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"description": "批次 ID",
|
"description": "批次 ID (UUID)",
|
||||||
"name": "batch_id",
|
"name": "batch_id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -396,8 +396,8 @@
|
|||||||
"summary": "删除批次",
|
"summary": "删除批次",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"description": "批次 ID",
|
"description": "批次 ID (UUID)",
|
||||||
"name": "batch_id",
|
"name": "batch_id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -419,6 +419,82 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/config": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "获取系统的完整配置文件内容(仅管理员)",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"管理员",
|
||||||
|
"配置"
|
||||||
|
],
|
||||||
|
"summary": "获取完整配置",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/config.Config"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "更新系统的配置文件内容(仅管理员)",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"管理员",
|
||||||
|
"配置"
|
||||||
|
],
|
||||||
|
"summary": "更新配置",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "新配置内容",
|
||||||
|
"name": "config",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/config.Config"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/model.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/login": {
|
"/admin/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "通过密码换取 JWT Token",
|
"description": "通过密码换取 JWT Token",
|
||||||
@@ -691,6 +767,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/config": {
|
||||||
|
"get": {
|
||||||
|
"description": "获取前端展示所需的非敏感配置数据",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"公共"
|
||||||
|
],
|
||||||
|
"summary": "获取公共配置",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/model.Response"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/public.PublicConfig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/files/{file_id}/download": {
|
"/api/files/{file_id}/download": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "根据文件 ID 下载单个文件",
|
"description": "根据文件 ID 下载单个文件",
|
||||||
@@ -703,8 +811,8 @@
|
|||||||
"summary": "下载单个文件",
|
"summary": "下载单个文件",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"description": "文件 ID",
|
"description": "文件 ID (UUID)",
|
||||||
"name": "file_id",
|
"name": "file_id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -824,6 +932,149 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.APITokenConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allowAdminAPI": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"maxTokens": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config.Config": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"apitoken": {
|
||||||
|
"$ref": "#/definitions/config.APITokenConfig"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"$ref": "#/definitions/config.DatabaseConfig"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"$ref": "#/definitions/config.SecurityConfig"
|
||||||
|
},
|
||||||
|
"site": {
|
||||||
|
"$ref": "#/definitions/config.SiteConfig"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"$ref": "#/definitions/config.StorageConfig"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"$ref": "#/definitions/config.UploadConfig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config.DatabaseConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config.SecurityConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"adminPasswordHash": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"jwtsecret": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pickupCodeLength": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"pickupFailLimit": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config.SiteConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config.StorageConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"local": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s3": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"accessKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bucket": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"secretKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"useSSL": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"webDAV": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"password": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config.UploadConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"maxBatchFiles": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"maxFileSizeMB": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"maxRetentionDays": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"model.APIToken": {
|
"model.APIToken": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -876,7 +1127,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"max_downloads": {
|
"max_downloads": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -904,13 +1155,13 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"batch_id": {
|
"batch_id": {
|
||||||
"type": "integer"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"mime_type": {
|
"mime_type": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -972,11 +1223,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"public.PublicConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"api_token": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"site": {
|
||||||
|
"$ref": "#/definitions/config.SiteConfig"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"$ref": "#/definitions/config.UploadConfig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"public.UploadResponse": {
|
"public.UploadResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"batch_id": {
|
"batch_id": {
|
||||||
"type": "integer"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"expire_at": {
|
"expire_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
302
package-lock.json
generated
302
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.562.0",
|
"lucide-vue-next": "^0.562.0",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
"vue-sonner": "^2.0.9"
|
"vue-sonner": "^2.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1391,6 +1393,12 @@
|
|||||||
"@vue/shared": "3.5.26"
|
"@vue/shared": "3.5.26"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/devtools-api": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vue/language-core": {
|
"node_modules/@vue/language-core": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.2.tgz",
|
||||||
@@ -1545,6 +1553,36 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
|
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
@@ -1566,6 +1604,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1578,6 +1628,15 @@
|
|||||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1587,6 +1646,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.4",
|
"version": "5.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||||
@@ -1612,6 +1685,51 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -1676,6 +1794,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1690,12 +1844,109 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -1972,6 +2223,36 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/muggle-string": {
|
"node_modules/muggle-string": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
@@ -2056,6 +2337,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz",
|
||||||
@@ -2338,6 +2625,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-router": {
|
||||||
|
"version": "4.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^6.6.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-sonner": {
|
"node_modules/vue-sonner": {
|
||||||
"version": "2.0.9",
|
"version": "2.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz",
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -1,17 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "vue-vite-shadcn-template",
|
"name": "file-relay-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"description": "文件中转站前端应用",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview --host",
|
||||||
|
"serve": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-vue-next": "^0.562.0",
|
"lucide-vue-next": "^0.562.0",
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
"vue-sonner": "^2.0.9"
|
"vue-sonner": "^2.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
import ComponentShowcase from './views/ComponentShowcase.vue'
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<ComponentShowcase />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
17
src/env.d.ts
vendored
Normal file
17
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string
|
||||||
|
readonly VITE_APP_TITLE: string
|
||||||
|
readonly VITE_APP_DESCRIPTION: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
306
src/lib/api.ts
Normal file
306
src/lib/api.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
// API 基础配置
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器 - 添加认证头
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token && config.url?.includes('/admin/')) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// 响应拦截器 - 统一错误处理
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401 && window.location.pathname.includes('/admin')) {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
window.location.href = '/admin/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// API 响应类型定义
|
||||||
|
interface ApiResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
id: string
|
||||||
|
batch_id: string
|
||||||
|
original_name: string
|
||||||
|
size: number
|
||||||
|
mime_type: string
|
||||||
|
storage_path: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileBatch {
|
||||||
|
id: string
|
||||||
|
pickup_code: string
|
||||||
|
type: 'file' | 'text'
|
||||||
|
content?: string
|
||||||
|
remark: string
|
||||||
|
expire_type: 'time' | 'download' | 'permanent'
|
||||||
|
expire_at?: string
|
||||||
|
max_downloads?: number
|
||||||
|
download_count: number
|
||||||
|
status: 'active' | 'expired' | 'deleted'
|
||||||
|
file_items: FileItem[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResponse {
|
||||||
|
pickup_code: string
|
||||||
|
batch_id: string
|
||||||
|
expire_at?: string
|
||||||
|
max_downloads?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PickupResponse {
|
||||||
|
type: 'file' | 'text'
|
||||||
|
content?: string
|
||||||
|
remark: string
|
||||||
|
expire_type: string
|
||||||
|
expire_at?: string
|
||||||
|
max_downloads?: number
|
||||||
|
download_count: number
|
||||||
|
files: FileItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchListResponse {
|
||||||
|
data: FileBatch[]
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface APIToken {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
scope: string
|
||||||
|
created_at: string
|
||||||
|
expire_at?: string
|
||||||
|
last_used_at?: string
|
||||||
|
revoked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公共 API
|
||||||
|
export const publicApi = {
|
||||||
|
// 上传文件
|
||||||
|
uploadFiles: (formData: FormData, config?: any): Promise<AxiosResponse<ApiResponse<UploadResponse>>> => {
|
||||||
|
return api.post('/api/batches', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 上传文本
|
||||||
|
uploadText: (data: {
|
||||||
|
content: string
|
||||||
|
remark?: string
|
||||||
|
expire_type?: string
|
||||||
|
expire_days?: number
|
||||||
|
max_downloads?: number
|
||||||
|
}): Promise<AxiosResponse<ApiResponse<UploadResponse>>> => {
|
||||||
|
return api.post('/api/batches/text', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取批次信息
|
||||||
|
getBatch: (pickupCode: string): Promise<AxiosResponse<ApiResponse<PickupResponse>>> => {
|
||||||
|
return api.get(`/api/batches/${pickupCode}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 下载单个文件
|
||||||
|
downloadFile: (fileId: string): Promise<AxiosResponse<Blob>> => {
|
||||||
|
return api.get(`/api/files/${fileId}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 批量下载文件
|
||||||
|
downloadBatch: (pickupCode: string): Promise<AxiosResponse<Blob>> => {
|
||||||
|
return api.get(`/api/batches/${pickupCode}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理 API
|
||||||
|
export const adminApi = {
|
||||||
|
// 管理员登录
|
||||||
|
login: (password: string): Promise<AxiosResponse<ApiResponse<{ token: string }>>> => {
|
||||||
|
return api.post('/admin/login', { password })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取批次列表
|
||||||
|
getBatches: (params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
status?: string
|
||||||
|
pickup_code?: string
|
||||||
|
}): Promise<AxiosResponse<ApiResponse<BatchListResponse>>> => {
|
||||||
|
return api.get('/admin/batches', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取批次详情
|
||||||
|
getBatchDetail: (batchId: string): Promise<AxiosResponse<ApiResponse<FileBatch>>> => {
|
||||||
|
return api.get(`/admin/batches/${batchId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新批次信息
|
||||||
|
updateBatch: (batchId: string, data: {
|
||||||
|
remark?: string
|
||||||
|
expire_type?: string
|
||||||
|
expire_at?: string
|
||||||
|
max_downloads?: number
|
||||||
|
status?: string
|
||||||
|
}): Promise<AxiosResponse<ApiResponse<FileBatch>>> => {
|
||||||
|
return api.put(`/admin/batches/${batchId}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除批次
|
||||||
|
deleteBatch: (batchId: string): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||||
|
return api.delete(`/admin/batches/${batchId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取 API Token 列表
|
||||||
|
getTokens: (): Promise<AxiosResponse<ApiResponse<APIToken[]>>> => {
|
||||||
|
return api.get('/admin/api-tokens')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建 API Token
|
||||||
|
createToken: (data: {
|
||||||
|
name: string
|
||||||
|
scope?: string
|
||||||
|
expire_at?: string
|
||||||
|
}): Promise<AxiosResponse<ApiResponse<{ token: string; data: APIToken }>>> => {
|
||||||
|
return api.post('/admin/api-tokens', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 撤销 API Token
|
||||||
|
revokeToken: (tokenId: number): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||||
|
return api.post(`/admin/api-tokens/${tokenId}/revoke`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除 API Token
|
||||||
|
deleteToken: (tokenId: number): Promise<AxiosResponse<ApiResponse<void>>> => {
|
||||||
|
return api.delete(`/admin/api-tokens/${tokenId}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
export const utils = {
|
||||||
|
// 格式化文件大小
|
||||||
|
formatFileSize: (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
formatDate: (dateString: string): string => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 复制到剪贴板
|
||||||
|
copyToClipboard: async (text: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
// 兼容旧浏览器
|
||||||
|
try {
|
||||||
|
const textArea = document.createElement('textarea')
|
||||||
|
textArea.value = text
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
return true
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error('复制失败:', fallbackErr)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
downloadBlob: (blob: Blob, filename: string) => {
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取文件图标类型
|
||||||
|
getFileTypeIcon: (filename: string): string => {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase()
|
||||||
|
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext || '')) {
|
||||||
|
return 'image'
|
||||||
|
} else if (['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv'].includes(ext || '')) {
|
||||||
|
return 'video'
|
||||||
|
} else if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext || '')) {
|
||||||
|
return 'audio'
|
||||||
|
} else if (['pdf'].includes(ext || '')) {
|
||||||
|
return 'pdf'
|
||||||
|
} else if (['doc', 'docx'].includes(ext || '')) {
|
||||||
|
return 'word'
|
||||||
|
} else if (['xls', 'xlsx'].includes(ext || '')) {
|
||||||
|
return 'excel'
|
||||||
|
} else if (['ppt', 'pptx'].includes(ext || '')) {
|
||||||
|
return 'powerpoint'
|
||||||
|
} else if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) {
|
||||||
|
return 'archive'
|
||||||
|
} else if (['txt', 'md', 'json', 'xml', 'csv'].includes(ext || '')) {
|
||||||
|
return 'text'
|
||||||
|
} else if (['js', 'ts', 'html', 'css', 'php', 'py', 'java', 'cpp', 'c'].includes(ext || '')) {
|
||||||
|
return 'code'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'file'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
||||||
|
// 类型定义导出
|
||||||
|
export type {
|
||||||
|
ApiResponse,
|
||||||
|
FileItem,
|
||||||
|
FileBatch,
|
||||||
|
UploadResponse,
|
||||||
|
PickupResponse,
|
||||||
|
BatchListResponse,
|
||||||
|
APIToken,
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).use(router).mount('#app')
|
||||||
|
|||||||
77
src/router/index.ts
Normal file
77
src/router/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
// 主页面组件
|
||||||
|
const HomePage = () => import('@/views/HomePage.vue')
|
||||||
|
const UploadPage = () => import('@/views/UploadPage.vue')
|
||||||
|
const PickupPage = () => import('@/views/PickupPage.vue')
|
||||||
|
|
||||||
|
// 管理页面组件
|
||||||
|
const AdminLogin = () => import('@/views/admin/AdminLogin.vue')
|
||||||
|
const AdminDashboard = () => import('@/views/admin/AdminDashboard.vue')
|
||||||
|
const BatchManagement = () => import('@/views/admin/BatchManagement.vue')
|
||||||
|
const TokenManagement = () => import('@/views/admin/TokenManagement.vue')
|
||||||
|
|
||||||
|
// 检查管理员权限
|
||||||
|
function requireAuth(_to: any, _from: any, next: any) {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token) {
|
||||||
|
next()
|
||||||
|
} else {
|
||||||
|
next('/admin/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'HomePage',
|
||||||
|
component: HomePage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/upload',
|
||||||
|
name: 'UploadPage',
|
||||||
|
component: UploadPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/pickup',
|
||||||
|
name: 'PickupPage',
|
||||||
|
component: PickupPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/pickup/:code',
|
||||||
|
name: 'PickupWithCode',
|
||||||
|
component: PickupPage,
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
// 管理页面路由
|
||||||
|
{
|
||||||
|
path: '/admin/login',
|
||||||
|
name: 'AdminLogin',
|
||||||
|
component: AdminLogin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
name: 'AdminDashboard',
|
||||||
|
component: AdminDashboard,
|
||||||
|
beforeEnter: requireAuth
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/batches',
|
||||||
|
name: 'BatchManagement',
|
||||||
|
component: BatchManagement,
|
||||||
|
beforeEnter: requireAuth
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/tokens',
|
||||||
|
name: 'TokenManagement',
|
||||||
|
component: TokenManagement,
|
||||||
|
beforeEnter: requireAuth
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
457
src/views/HomePage.vue
Normal file
457
src/views/HomePage.vue
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<!-- 主要内容区域 -->
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<!-- 取件区域 -->
|
||||||
|
<Card v-if="!batchData" class="shadow-lg border-0 mb-6">
|
||||||
|
<CardContent class="pt-8 pb-6">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 mb-2">文件取件</h2>
|
||||||
|
<p class="text-gray-600">请输入您的取件码</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<Input
|
||||||
|
v-model="pickupCode"
|
||||||
|
placeholder="请输入取件码..."
|
||||||
|
class="flex-1 text-center text-lg py-3 tracking-widest"
|
||||||
|
@keyup.enter="handlePickup"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="handlePickup"
|
||||||
|
:disabled="!pickupCode || loading"
|
||||||
|
size="lg"
|
||||||
|
class="px-8"
|
||||||
|
>
|
||||||
|
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ loading ? '获取中...' : '提取文件' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<Button variant="outline" @click="pasteFromClipboard" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
粘贴取件码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 文件展示区域 -->
|
||||||
|
<div v-if="batchData" class="space-y-6">
|
||||||
|
<!-- 批次信息 -->
|
||||||
|
<Card class="shadow-lg border-0">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
提取成功
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
取件码: {{ currentCode }}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Badge :variant="batchData.download_count < (batchData.max_downloads || Infinity) ? 'default' : 'secondary'">
|
||||||
|
{{ batchData.type === 'text' ? '文本内容' : `${batchData.files?.length || 0} 个文件` }}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" @click="reset">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
重新取件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">类型:</span>
|
||||||
|
<span>{{ batchData.type === 'text' ? '文本' : '文件' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">下载次数:</span>
|
||||||
|
<span>{{ batchData.download_count }}{{ batchData.max_downloads ? ` / ${batchData.max_downloads}` : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="batchData.expire_at" class="flex justify-between">
|
||||||
|
<span class="text-gray-600">过期时间:</span>
|
||||||
|
<span>{{ formatDate(batchData.expire_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="batchData.remark" class="flex justify-between">
|
||||||
|
<span class="text-gray-600">备注:</span>
|
||||||
|
<span class="truncate">{{ batchData.remark }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 文本内容 -->
|
||||||
|
<Card v-if="batchData.type === 'text'" class="shadow-lg border-0">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<CardTitle>文本内容</CardTitle>
|
||||||
|
<Button @click="copyText" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
复制文本
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg border max-h-96 overflow-y-auto">
|
||||||
|
<pre class="whitespace-pre-wrap text-sm">{{ batchData.content }}</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 文件列表 -->
|
||||||
|
<Card v-if="batchData.type === 'file' && batchData.files" class="shadow-lg border-0">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>文件列表</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
共 {{ batchData.files.length }} 个文件,总大小 {{ totalFileSize }}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
@click="downloadAll"
|
||||||
|
:disabled="downloading"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<svg v-if="downloading" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M7 7h10a2 2 0 012 2v6a2 2 0 01-2 2H7a2 2 0 01-2-2V9a2 2 0 012-2z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ downloading ? '打包中...' : '打包下载' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="file in batchData.files"
|
||||||
|
:key="file.id"
|
||||||
|
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3 flex-1">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||||
|
<component :is="getFileIconComponent(file.original_name)" class="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{{ file.original_name }}
|
||||||
|
</h4>
|
||||||
|
<div class="flex items-center space-x-4 text-xs text-gray-500">
|
||||||
|
<span>{{ formatFileSize(file.size) }}</span>
|
||||||
|
<span>{{ file.mime_type }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
@click="downloadFile(file)"
|
||||||
|
:disabled="downloadingFiles.has(file.id)"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<svg v-if="downloadingFiles.has(file.id)" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 发送文件入口 -->
|
||||||
|
<div v-if="!batchData" class="text-center">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="bg-gray-50 px-4 text-gray-500">或者</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<Button @click="router.push('/upload')" size="lg" variant="outline" class="w-full sm:w-auto">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||||
|
</svg>
|
||||||
|
发送文件或文本
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast 组件 -->
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, h } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
// 组件导入
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
// API 和工具导入
|
||||||
|
import { publicApi, utils, type PickupResponse, type FileItem } from '@/lib/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const pickupCode = ref('')
|
||||||
|
const currentCode = ref('')
|
||||||
|
const batchData = ref<PickupResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const downloading = ref(false)
|
||||||
|
const downloadingFiles = ref(new Set<string>())
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const totalFileSize = computed(() => {
|
||||||
|
if (!batchData.value?.files) return '0 B'
|
||||||
|
const total = batchData.value.files.reduce((sum, file) => sum + file.size, 0)
|
||||||
|
return utils.formatFileSize(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const handlePickup = async () => {
|
||||||
|
if (!pickupCode.value.trim()) {
|
||||||
|
toast.warning('请输入取件码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await publicApi.getBatch(pickupCode.value.trim())
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
batchData.value = response.data.data
|
||||||
|
currentCode.value = pickupCode.value.trim()
|
||||||
|
toast.success('获取成功!')
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '获取失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// 不在控制台显示业务异常(如404等)
|
||||||
|
if (error.response?.status >= 400 && error.response?.status < 500) {
|
||||||
|
// 客户端错误,只显示toast,不打印到控制台
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
toast.error('取件码不存在或已过期')
|
||||||
|
} else if (error.response?.status === 410) {
|
||||||
|
toast.error('文件已过期或达到下载限制')
|
||||||
|
} else {
|
||||||
|
toast.error(error.response?.data?.msg || '获取失败,请重试')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 服务器错误或网络错误,记录到控制台用于调试
|
||||||
|
console.error('获取批次失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '获取失败,请重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pasteFromClipboard = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
if (text.trim()) {
|
||||||
|
pickupCode.value = text.trim()
|
||||||
|
toast.success('已粘贴取件码')
|
||||||
|
} else {
|
||||||
|
toast.warning('剪贴板中没有内容')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('读取剪贴板失败,请手动输入')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyText = async () => {
|
||||||
|
if (batchData.value?.content) {
|
||||||
|
const success = await utils.copyToClipboard(batchData.value.content)
|
||||||
|
if (success) {
|
||||||
|
toast.success('文本已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
toast.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = async (file: FileItem) => {
|
||||||
|
downloadingFiles.value.add(file.id)
|
||||||
|
try {
|
||||||
|
const response = await publicApi.downloadFile(file.id)
|
||||||
|
utils.downloadBlob(response.data, file.original_name)
|
||||||
|
toast.success(`下载 ${file.original_name} 成功`)
|
||||||
|
} catch (error: any) {
|
||||||
|
// 不在控制台显示业务异常(如404等)
|
||||||
|
if (error.response?.status >= 400 && error.response?.status < 500) {
|
||||||
|
// 客户端错误,只显示toast,不打印到控制台
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
toast.error('文件不存在')
|
||||||
|
} else if (error.response?.status === 410) {
|
||||||
|
toast.error('文件已过期')
|
||||||
|
} else {
|
||||||
|
toast.error('下载失败,请重试')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 服务器错误或网络错误,记录到控制台用于调试
|
||||||
|
console.error('下载文件失败:', error)
|
||||||
|
toast.error('下载失败,请重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
downloadingFiles.value.delete(file.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadAll = async () => {
|
||||||
|
if (!currentCode.value) return
|
||||||
|
|
||||||
|
downloading.value = true
|
||||||
|
try {
|
||||||
|
const response = await publicApi.downloadBatch(currentCode.value)
|
||||||
|
const filename = `files_${currentCode.value}.zip`
|
||||||
|
utils.downloadBlob(response.data, filename)
|
||||||
|
toast.success('打包下载成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
// 不在控制台显示业务异常(如404等)
|
||||||
|
if (error.response?.status >= 400 && error.response?.status < 500) {
|
||||||
|
// 客户端错误,只显示toast,不打印到控制台
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
toast.error('文件批次不存在')
|
||||||
|
} else if (error.response?.status === 410) {
|
||||||
|
toast.error('文件已过期')
|
||||||
|
} else {
|
||||||
|
toast.error('下载失败,请重试')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 服务器错误或网络错误,记录到控制台用于调试
|
||||||
|
console.error('打包下载失败:', error)
|
||||||
|
toast.error('下载失败,请重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
downloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
batchData.value = null
|
||||||
|
currentCode.value = ''
|
||||||
|
pickupCode.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
return utils.formatFileSize(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return utils.formatDate(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图标组件映射
|
||||||
|
const getFileIconComponent = (filename: string) => {
|
||||||
|
const icon = utils.getFileTypeIcon(filename)
|
||||||
|
|
||||||
|
const iconMap: Record<string, any> = {
|
||||||
|
image: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' })
|
||||||
|
]),
|
||||||
|
video: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z' })
|
||||||
|
]),
|
||||||
|
audio: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3' })
|
||||||
|
]),
|
||||||
|
archive: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 8l4 4 4-4m6-4v12a2 2 0 01-2 2H7a2 2 0 01-2-2V4a2 2 0 012-2h10a2 2 0 012 2z' })
|
||||||
|
]),
|
||||||
|
file: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return iconMap[icon] || iconMap.file
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框样式 */
|
||||||
|
.tracking-widest {
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片阴影 */
|
||||||
|
.shadow-lg {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停效果 */
|
||||||
|
.hover\:bg-gray-100:hover {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条 */
|
||||||
|
.overflow-y-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
485
src/views/PickupPage.vue
Normal file
485
src/views/PickupPage.vue
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<!-- 页面标题和导航 -->
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">文件取件</h1>
|
||||||
|
<p class="text-gray-600 mt-2">使用取件码获取文件</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" @click="router.push('/')">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-1a1 1 0 011-1h2a1 1 0 011 1v1a1 1 0 001 1m-6 0h6"/>
|
||||||
|
</svg>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<!-- 取件码输入区域 -->
|
||||||
|
<Card v-if="!batchData" class="border-0 shadow-xl mb-8">
|
||||||
|
<CardHeader class="text-center">
|
||||||
|
<CardTitle class="text-2xl text-gray-800">输入取件码</CardTitle>
|
||||||
|
<CardDescription class="text-base">
|
||||||
|
请输入您收到的取件码来获取文件
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Input
|
||||||
|
v-model="inputCode"
|
||||||
|
placeholder="请输入取件码..."
|
||||||
|
class="flex-1 text-lg py-6 text-center tracking-widest"
|
||||||
|
@keyup.enter="fetchBatch"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="fetchBatch"
|
||||||
|
:disabled="!inputCode || loading"
|
||||||
|
size="lg"
|
||||||
|
class="px-8 py-6 text-lg"
|
||||||
|
>
|
||||||
|
<svg v-if="loading" class="animate-spin -ml-1 mr-3 h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ loading ? '获取中...' : '获取文件' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<Button variant="outline" @click="pasteFromClipboard" class="text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
从剪贴板粘贴
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 文件信息展示 -->
|
||||||
|
<div v-if="batchData" class="space-y-6">
|
||||||
|
<!-- 批次信息 -->
|
||||||
|
<Card class="border-0 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle class="flex items-center">
|
||||||
|
<svg class="w-6 h-6 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
取件成功
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
取件码: {{ currentCode }}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
:variant="batchData.download_count < (batchData.max_downloads || Infinity) ? 'default' : 'secondary'"
|
||||||
|
>
|
||||||
|
{{ batchData.type === 'text' ? '文本内容' : `${batchData.files?.length || 0} 个文件` }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">类型:</span>
|
||||||
|
<span>{{ batchData.type === 'text' ? '文本' : '文件' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">下载次数:</span>
|
||||||
|
<span>{{ batchData.download_count }}{{ batchData.max_downloads ? ` / ${batchData.max_downloads}` : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="batchData.expire_at" class="flex justify-between">
|
||||||
|
<span class="text-gray-600">过期时间:</span>
|
||||||
|
<span>{{ formatDate(batchData.expire_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="batchData.remark" class="flex justify-between">
|
||||||
|
<span class="text-gray-600">备注:</span>
|
||||||
|
<span class="truncate">{{ batchData.remark }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 文本内容 -->
|
||||||
|
<Card v-if="batchData.type === 'text'" class="border-0 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<CardTitle>文本内容</CardTitle>
|
||||||
|
<Button @click="copyText" variant="outline" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
复制文本
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg border max-h-96 overflow-y-auto">
|
||||||
|
<pre class="whitespace-pre-wrap text-sm">{{ batchData.content }}</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 文件列表 -->
|
||||||
|
<Card v-if="batchData.type === 'file' && batchData.files" class="border-0 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>文件列表</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
共 {{ batchData.files.length }} 个文件,总大小 {{ totalFileSize }}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<Button
|
||||||
|
@click="downloadAll"
|
||||||
|
:disabled="downloading"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<svg v-if="downloading" class="animate-spin -ml-1 mr-3 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M7 7h10a2 2 0 012 2v6a2 2 0 01-2 2H7a2 2 0 01-2-2V9a2 2 0 012-2z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ downloading ? '打包中...' : '打包下载' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="file in batchData.files"
|
||||||
|
:key="file.id"
|
||||||
|
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-4 flex-1">
|
||||||
|
<div class="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||||
|
<component :is="getFileIconComponent(file.original_name)" class="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-base font-medium text-gray-900 truncate">
|
||||||
|
{{ file.original_name }}
|
||||||
|
</h4>
|
||||||
|
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||||
|
<span>{{ formatFileSize(file.size) }}</span>
|
||||||
|
<span>{{ file.mime_type }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
@click="downloadFile(file)"
|
||||||
|
:disabled="downloadingFiles.has(file.id)"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<svg v-if="downloadingFiles.has(file.id)" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 重新取件 -->
|
||||||
|
<Card class="border-0 shadow-xl">
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<Button variant="outline" @click="reset">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
使用其他取件码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast 组件 -->
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, h } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
// 组件导入
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
// API 和工具导入
|
||||||
|
import { publicApi, utils, type PickupResponse, type FileItem } from '@/lib/api'
|
||||||
|
|
||||||
|
// 定义 props
|
||||||
|
interface Props {
|
||||||
|
code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const inputCode = ref('')
|
||||||
|
const currentCode = ref('')
|
||||||
|
const batchData = ref<PickupResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const downloading = ref(false)
|
||||||
|
const downloadingFiles = ref(new Set<string>())
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const totalFileSize = computed(() => {
|
||||||
|
if (!batchData.value?.files) return '0 B'
|
||||||
|
const total = batchData.value.files.reduce((sum, file) => sum + file.size, 0)
|
||||||
|
return utils.formatFileSize(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 图标组件映射
|
||||||
|
const getFileIconComponent = (filename: string) => {
|
||||||
|
const icon = utils.getFileTypeIcon(filename)
|
||||||
|
|
||||||
|
const iconMap: Record<string, any> = {
|
||||||
|
image: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' })
|
||||||
|
]),
|
||||||
|
video: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z' })
|
||||||
|
]),
|
||||||
|
audio: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3' })
|
||||||
|
]),
|
||||||
|
archive: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 8l4 4 4-4m6-4v12a2 2 0 01-2 2H7a2 2 0 01-2-2V4a2 2 0 012-2h10a2 2 0 012 2z' })
|
||||||
|
]),
|
||||||
|
pdf: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' })
|
||||||
|
]),
|
||||||
|
code: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4' })
|
||||||
|
]),
|
||||||
|
text: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' })
|
||||||
|
]),
|
||||||
|
file: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return iconMap[icon] || iconMap.file
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const fetchBatch = async (code?: string) => {
|
||||||
|
const pickupCode = code || inputCode.value.trim()
|
||||||
|
if (!pickupCode) {
|
||||||
|
toast.warning('请输入取件码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await publicApi.getBatch(pickupCode)
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
batchData.value = response.data.data
|
||||||
|
currentCode.value = pickupCode
|
||||||
|
toast.success('获取成功!')
|
||||||
|
|
||||||
|
// 更新 URL
|
||||||
|
if (route.path !== `/pickup/${pickupCode}`) {
|
||||||
|
router.replace(`/pickup/${pickupCode}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '获取失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取批次失败:', error)
|
||||||
|
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
toast.error('取件码不存在或已过期')
|
||||||
|
} else if (error.response?.status === 410) {
|
||||||
|
toast.error('文件已过期或达到下载限制')
|
||||||
|
} else {
|
||||||
|
toast.error(error.response?.data?.msg || '获取失败,请重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pasteFromClipboard = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
if (text.trim()) {
|
||||||
|
inputCode.value = text.trim()
|
||||||
|
toast.success('已粘贴取件码')
|
||||||
|
} else {
|
||||||
|
toast.warning('剪贴板中没有内容')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('读取剪贴板失败,请手动输入')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyText = async () => {
|
||||||
|
if (batchData.value?.content) {
|
||||||
|
const success = await utils.copyToClipboard(batchData.value.content)
|
||||||
|
if (success) {
|
||||||
|
toast.success('文本已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
toast.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = async (file: FileItem) => {
|
||||||
|
downloadingFiles.value.add(file.id)
|
||||||
|
try {
|
||||||
|
const response = await publicApi.downloadFile(file.id)
|
||||||
|
utils.downloadBlob(response.data, file.original_name)
|
||||||
|
toast.success(`下载 ${file.original_name} 成功`)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('下载文件失败:', error)
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
toast.error('文件不存在')
|
||||||
|
} else if (error.response?.status === 410) {
|
||||||
|
toast.error('文件已过期')
|
||||||
|
} else {
|
||||||
|
toast.error('下载失败,请重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
downloadingFiles.value.delete(file.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadAll = async () => {
|
||||||
|
if (!currentCode.value) return
|
||||||
|
|
||||||
|
downloading.value = true
|
||||||
|
try {
|
||||||
|
const response = await publicApi.downloadBatch(currentCode.value)
|
||||||
|
const filename = `files_${currentCode.value}.zip`
|
||||||
|
utils.downloadBlob(response.data, filename)
|
||||||
|
toast.success('打包下载成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('打包下载失败:', error)
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
toast.error('文件批次不存在')
|
||||||
|
} else if (error.response?.status === 410) {
|
||||||
|
toast.error('文件已过期')
|
||||||
|
} else {
|
||||||
|
toast.error('下载失败,请重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
downloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
return utils.formatFileSize(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return utils.formatDate(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
batchData.value = null
|
||||||
|
currentCode.value = ''
|
||||||
|
inputCode.value = ''
|
||||||
|
router.push('/pickup')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载
|
||||||
|
onMounted(() => {
|
||||||
|
// 如果 URL 中有取件码参数,自动获取
|
||||||
|
if (props.code) {
|
||||||
|
inputCode.value = props.code
|
||||||
|
fetchBatch(props.code)
|
||||||
|
} else if (route.params.code) {
|
||||||
|
inputCode.value = route.params.code as string
|
||||||
|
fetchBatch(route.params.code as string)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文本内容滚动条 */
|
||||||
|
.overflow-y-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(156, 163, 175, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框样式 */
|
||||||
|
.tracking-widest {
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片阴影 */
|
||||||
|
.shadow-xl {
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件项悬停效果 */
|
||||||
|
.hover\:bg-gray-100:hover {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
682
src/views/UploadPage.vue
Normal file
682
src/views/UploadPage.vue
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">发送文件</h1>
|
||||||
|
<p class="text-gray-600">上传文件或发送文本内容,获取取件码分享给他人</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传类型切换 -->
|
||||||
|
<div class="w-full">
|
||||||
|
<!-- 自定义Tab组件 -->
|
||||||
|
<div class="flex rounded-lg bg-gray-100 p-1 mb-8">
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'file'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 flex items-center justify-center py-3 px-4 text-lg font-medium rounded-md transition-all duration-200',
|
||||||
|
activeTab === 'file'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
文件上传
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'text'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 flex items-center justify-center py-3 px-4 text-lg font-medium rounded-md transition-all duration-200',
|
||||||
|
activeTab === 'text'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
文本保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- 文件上传界面 -->
|
||||||
|
<div v-show="activeTab === 'file'" class="space-y-6">
|
||||||
|
<!-- 文件选择区域 -->
|
||||||
|
<Card class="border-0 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>选择文件</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
支持拖拽或点击选择文件,可批量上传多个文件
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-gray-300 rounded-lg p-12 text-center hover:border-gray-400 transition-colors cursor-pointer"
|
||||||
|
:class="{
|
||||||
|
'border-blue-400 bg-blue-50': isDragging,
|
||||||
|
'border-gray-300': !isDragging
|
||||||
|
}"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave.prevent="isDragging = false"
|
||||||
|
@drop.prevent="handleFileDrop"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svg class="mx-auto h-16 w-16 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-lg text-gray-600">
|
||||||
|
{{ isDragging ? '释放文件到此处' : '点击选择文件或拖拽到此处' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
支持任意格式文件,单文件最大 100MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已选择的文件列表 -->
|
||||||
|
<div v-if="selectedFiles.length > 0" class="mt-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-3">
|
||||||
|
已选择 {{ selectedFiles.length }} 个文件
|
||||||
|
<span class="text-gray-500">({{ totalSize }})</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(file, index) in selectedFiles"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<div class="w-8 h-8 rounded bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<component :is="getFileIconComponent(file.name)" class="w-4 h-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{{ file.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{ formatFileSize(file.size) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="removeFile(index)"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="clearFiles"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
清空列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文本保存界面 -->
|
||||||
|
<div v-show="activeTab === 'text'" class="space-y-6">
|
||||||
|
<Card class="border-0 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>输入文本内容</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
输入要保存和分享的文本内容
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div>
|
||||||
|
<Textarea
|
||||||
|
v-model="textContent"
|
||||||
|
placeholder="请输入要保存的文本内容..."
|
||||||
|
class="min-h-[300px] resize-none"
|
||||||
|
rows="12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-2 flex justify-between text-sm text-gray-500">
|
||||||
|
<span>{{ textContent.length }} 个字符</span>
|
||||||
|
<Button @click="pasteText" variant="ghost" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
粘贴
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 共用配置区域 -->
|
||||||
|
<Card v-if="activeTab === 'file' ? selectedFiles.length > 0 : textContent.trim()" class="border-0 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ activeTab === 'file' ? '上传' : '保存' }}配置</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
设置过期策略和备注信息
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-6">
|
||||||
|
<!-- 过期策略 -->
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">过期策略</Label>
|
||||||
|
<RadioGroup v-model="expireType" class="mt-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="time" id="expire-time" />
|
||||||
|
<Label for="expire-time">按时间过期</Label>
|
||||||
|
</div>
|
||||||
|
<div v-if="expireType === 'time'" class="ml-6 mt-2">
|
||||||
|
<Select v-model="expireDays">
|
||||||
|
<SelectTrigger class="w-48">
|
||||||
|
<SelectValue placeholder="选择过期时间" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1天后</SelectItem>
|
||||||
|
<SelectItem value="3">3天后</SelectItem>
|
||||||
|
<SelectItem value="7">7天后</SelectItem>
|
||||||
|
<SelectItem value="15">15天后</SelectItem>
|
||||||
|
<SelectItem value="30">30天后</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="download" id="expire-download" />
|
||||||
|
<Label for="expire-download">按{{ activeTab === 'file' ? '下载' : '访问' }}次数</Label>
|
||||||
|
</div>
|
||||||
|
<div v-if="expireType === 'download'" class="ml-6 mt-2">
|
||||||
|
<Select v-model="maxDownloads">
|
||||||
|
<SelectTrigger class="w-48">
|
||||||
|
<SelectValue placeholder="选择次数限制" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1次后删除</SelectItem>
|
||||||
|
<SelectItem value="3">3次后删除</SelectItem>
|
||||||
|
<SelectItem value="5">5次后删除</SelectItem>
|
||||||
|
<SelectItem value="10">10次后删除</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 备注信息 -->
|
||||||
|
<div>
|
||||||
|
<Label for="remark" class="text-sm font-medium">备注信息</Label>
|
||||||
|
<Textarea
|
||||||
|
id="remark"
|
||||||
|
v-model="remark"
|
||||||
|
placeholder="可选:添加备注说明..."
|
||||||
|
class="mt-2 resize-none"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
@click="activeTab === 'file' ? uploadFiles() : uploadText()"
|
||||||
|
:disabled="uploading || (activeTab === 'file' ? selectedFiles.length === 0 : !textContent.trim())"
|
||||||
|
class="flex-1"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<svg v-if="uploading" class="animate-spin -ml-1 mr-3 h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ getActionButtonText() }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="router.push('/')"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传进度 -->
|
||||||
|
<div v-if="uploading" class="space-y-2">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>{{ activeTab === 'file' ? '上传' : '保存' }}进度</span>
|
||||||
|
<span>{{ uploadProgress }}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress :value="uploadProgress" class="h-2" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成功对话框 -->
|
||||||
|
<Dialog :open="showSuccessDialog">
|
||||||
|
<DialogContent class="sm:max-w-[425px]" @interact-outside="$event.preventDefault()" @escape-key-down="$event.preventDefault()">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="text-green-600">
|
||||||
|
<svg class="w-6 h-6 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
{{ activeTab === 'file' ? '文件上传成功' : '文本保存成功' }}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
您的内容已成功保存,请保存以下取件码
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="py-4">
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 mb-1">取件码</p>
|
||||||
|
<p class="text-2xl font-mono font-bold text-gray-900 tracking-widest">
|
||||||
|
{{ uploadResult?.pickup_code }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button @click="copyPickupCode" variant="outline" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploadResult?.expire_at" class="mt-3 text-sm text-gray-600">
|
||||||
|
过期时间: {{ formatDate(uploadResult.expire_at) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="uploadResult?.max_downloads" class="mt-1 text-sm text-gray-600">
|
||||||
|
最大访问次数: {{ uploadResult.max_downloads }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="space-x-2">
|
||||||
|
<Button @click="continueUpload" variant="outline">
|
||||||
|
继续{{ activeTab === 'file' ? '上传' : '保存' }}
|
||||||
|
</Button>
|
||||||
|
<Button @click="router.push('/')">
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast 组件 -->
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, h } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
// 组件导入
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
// API 和工具导入
|
||||||
|
import { publicApi, utils, type UploadResponse } from '@/lib/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const activeTab = ref('file')
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const selectedFiles = ref<File[]>([])
|
||||||
|
const textContent = ref('')
|
||||||
|
|
||||||
|
// 共用配置
|
||||||
|
const expireType = ref('time')
|
||||||
|
const expireDays = ref('7')
|
||||||
|
const maxDownloads = ref('3')
|
||||||
|
const remark = ref('')
|
||||||
|
|
||||||
|
// 上传状态
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
const showSuccessDialog = ref(false)
|
||||||
|
const uploadResult = ref<UploadResponse | null>(null)
|
||||||
|
|
||||||
|
// DOM 引用
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const totalSize = computed(() => {
|
||||||
|
const total = selectedFiles.value.reduce((sum, file) => sum + file.size, 0)
|
||||||
|
return utils.formatFileSize(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const files = target.files
|
||||||
|
if (files) {
|
||||||
|
addFiles(Array.from(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileDrop = (event: DragEvent) => {
|
||||||
|
isDragging.value = false
|
||||||
|
const files = event.dataTransfer?.files
|
||||||
|
if (files) {
|
||||||
|
addFiles(Array.from(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFiles = (files: File[]) => {
|
||||||
|
// 过滤重复文件
|
||||||
|
const newFiles = files.filter(file => {
|
||||||
|
return !selectedFiles.value.some(existing =>
|
||||||
|
existing.name === file.name && existing.size === file.size
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (newFiles.length > 0) {
|
||||||
|
selectedFiles.value.push(...newFiles)
|
||||||
|
toast.success(`添加了 ${newFiles.length} 个文件`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
const oversizedFiles = newFiles.filter(file => file.size > 100 * 1024 * 1024)
|
||||||
|
if (oversizedFiles.length > 0) {
|
||||||
|
toast.warning(`${oversizedFiles.length} 个文件超过 100MB 限制`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
selectedFiles.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFiles = () => {
|
||||||
|
selectedFiles.value = []
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFiles = async () => {
|
||||||
|
if (selectedFiles.value.length === 0) {
|
||||||
|
toast.warning('请先选择要上传的文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
uploadProgress.value = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
// 添加文件
|
||||||
|
selectedFiles.value.forEach(file => {
|
||||||
|
formData.append('files', file)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加配置
|
||||||
|
const config = {
|
||||||
|
expire_type: expireType.value,
|
||||||
|
[expireType.value === 'time' ? 'expire_days' : 'max_downloads']:
|
||||||
|
expireType.value === 'time' ? parseInt(expireDays.value) : parseInt(maxDownloads.value),
|
||||||
|
remark: remark.value.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(config).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
formData.append(key, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await publicApi.uploadFiles(formData, {
|
||||||
|
onUploadProgress: (progressEvent: any) => {
|
||||||
|
if (progressEvent.total) {
|
||||||
|
uploadProgress.value = Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
uploadResult.value = response.data.data
|
||||||
|
showSuccessDialog.value = true
|
||||||
|
toast.success('文件上传成功!')
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '上传失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
handleUploadError(error, '文件上传失败')
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
uploadProgress.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pasteText = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
if (text.trim()) {
|
||||||
|
textContent.value = text
|
||||||
|
toast.success('已粘贴文本内容')
|
||||||
|
} else {
|
||||||
|
toast.warning('剪贴板中没有内容')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('读取剪贴板失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadText = async () => {
|
||||||
|
if (!textContent.value.trim()) {
|
||||||
|
toast.warning('请输入文本内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
uploadProgress.value = 0
|
||||||
|
|
||||||
|
// 模拟文本上传进度
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
if (uploadProgress.value < 90) {
|
||||||
|
uploadProgress.value += 10
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = {
|
||||||
|
content: textContent.value.trim(),
|
||||||
|
expire_type: expireType.value,
|
||||||
|
[expireType.value === 'time' ? 'expire_days' : 'max_downloads']:
|
||||||
|
expireType.value === 'time' ? parseInt(expireDays.value) : parseInt(maxDownloads.value),
|
||||||
|
remark: remark.value.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await publicApi.uploadText(config)
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
uploadProgress.value = 100
|
||||||
|
uploadResult.value = response.data.data
|
||||||
|
showSuccessDialog.value = true
|
||||||
|
toast.success('文本保存成功!')
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '保存失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
handleUploadError(error, '文本保存失败')
|
||||||
|
} finally {
|
||||||
|
clearInterval(progressInterval)
|
||||||
|
uploading.value = false
|
||||||
|
uploadProgress.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadError = (error: any, defaultMessage: string) => {
|
||||||
|
// 不在控制台显示业务异常(如404等)
|
||||||
|
if (error.response?.status >= 400 && error.response?.status < 500) {
|
||||||
|
// 客户端错误,只显示toast,不打印到控制台
|
||||||
|
toast.error(error.response?.data?.msg || defaultMessage)
|
||||||
|
} else {
|
||||||
|
// 服务器错误或网络错误,记录到控制台用于调试
|
||||||
|
console.error(`${defaultMessage}:`, error)
|
||||||
|
toast.error(error.response?.data?.msg || `${defaultMessage},请重试`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyPickupCode = async () => {
|
||||||
|
if (uploadResult.value?.pickup_code) {
|
||||||
|
const success = await utils.copyToClipboard(uploadResult.value.pickup_code)
|
||||||
|
if (success) {
|
||||||
|
toast.success('取件码已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
toast.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continueUpload = () => {
|
||||||
|
showSuccessDialog.value = false
|
||||||
|
uploadResult.value = null
|
||||||
|
|
||||||
|
// 清空表单内容
|
||||||
|
if (activeTab.value === 'file') {
|
||||||
|
clearFiles()
|
||||||
|
} else {
|
||||||
|
textContent.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置共用配置为默认值
|
||||||
|
expireType.value = 'time'
|
||||||
|
expireDays.value = '7'
|
||||||
|
maxDownloads.value = '3'
|
||||||
|
remark.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionButtonText = () => {
|
||||||
|
if (uploading.value) {
|
||||||
|
return activeTab.value === 'file' ? `上传中... ${uploadProgress.value}%` : `保存中... ${uploadProgress.value}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab.value === 'file') {
|
||||||
|
return `上传 ${selectedFiles.value.length} 个文件`
|
||||||
|
} else {
|
||||||
|
return '保存文本'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
return utils.formatFileSize(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return utils.formatDate(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图标组件映射
|
||||||
|
const getFileIconComponent = (filename: string) => {
|
||||||
|
const icon = utils.getFileTypeIcon(filename)
|
||||||
|
|
||||||
|
const iconMap: Record<string, any> = {
|
||||||
|
image: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' })
|
||||||
|
]),
|
||||||
|
video: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z' })
|
||||||
|
]),
|
||||||
|
audio: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3' })
|
||||||
|
]),
|
||||||
|
archive: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 8l4 4 4-4m6-4v12a2 2 0 01-2 2H7a2 2 0 01-2-2V4a2 2 0 012-2h10a2 2 0 012 2z' })
|
||||||
|
]),
|
||||||
|
file: () => h('svg', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||||
|
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return iconMap[icon] || iconMap.file
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖拽状态 */
|
||||||
|
.border-blue-400 {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-blue-50 {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片阴影 */
|
||||||
|
.shadow-lg {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条 */
|
||||||
|
.overflow-y-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
434
src/views/admin/AdminDashboard.vue
Normal file
434
src/views/admin/AdminDashboard.vue
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- 导航栏 -->
|
||||||
|
<nav class="bg-white border-b border-gray-200">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">文件中转站管理</h1>
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:ml-6 md:flex md:space-x-8">
|
||||||
|
<router-link
|
||||||
|
to="/admin"
|
||||||
|
class="border-indigo-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
:class="$route.path === '/admin' ? 'border-indigo-500 text-gray-900' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'"
|
||||||
|
>
|
||||||
|
概览
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/admin/batches"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
:class="$route.path.includes('/admin/batches') ? 'border-indigo-500 text-gray-900' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'"
|
||||||
|
>
|
||||||
|
文件管理
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/admin/tokens"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
:class="$route.path.includes('/admin/tokens') ? 'border-indigo-500 text-gray-900' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'"
|
||||||
|
>
|
||||||
|
API 管理
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<Button variant="outline" @click="router.push('/')" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-1a1 1 0 011-1h2a1 1 0 011 1v1a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
前往前台
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" @click="handleLogout" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||||
|
</svg>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 主要内容 -->
|
||||||
|
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">管理概览</h2>
|
||||||
|
<p class="mt-2 text-gray-600">系统运行状态和统计信息</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<!-- 总批次数 -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2h4l2 2h4a2 2 0 012 2v2M7 7V3a2 2 0 012-2h6a2 2 0 012 2v4"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">总批次数</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">{{ stats.totalBatches || 0 }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 活跃批次 -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">活跃批次</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">{{ stats.activeBatches || 0 }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 已过期批次 -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">已过期批次</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">{{ stats.expiredBatches || 0 }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 总文件数 -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">总文件数</p>
|
||||||
|
<p class="text-2xl font-semibold text-gray-900">{{ stats.totalFiles || 0 }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近批次 -->
|
||||||
|
<Card class="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>最近批次</CardTitle>
|
||||||
|
<CardDescription>最新创建的文件批次</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" @click="router.push('/admin/batches')" size="sm">
|
||||||
|
查看全部
|
||||||
|
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="loading" class="space-y-4">
|
||||||
|
<Skeleton class="h-4 w-full" />
|
||||||
|
<Skeleton class="h-4 w-3/4" />
|
||||||
|
<Skeleton class="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="recentBatches.length === 0" class="text-center py-8">
|
||||||
|
<svg class="w-12 h-12 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500">暂无批次数据</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>取件码</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>文件数</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead>过期时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="batch in recentBatches" :key="batch.id">
|
||||||
|
<TableCell class="font-medium font-mono">{{ batch.pickup_code }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ batch.type === 'text' ? '文本' : '文件' }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="getStatusVariant(batch.status)">
|
||||||
|
{{ getStatusText(batch.status) }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ batch.file_items?.length || 0 }}</TableCell>
|
||||||
|
<TableCell>{{ formatDate(batch.created_at) }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span v-if="batch.expire_at">{{ formatDate(batch.expire_at) }}</span>
|
||||||
|
<span v-else class="text-gray-500">永不过期</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 快速操作 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>快速操作</CardTitle>
|
||||||
|
<CardDescription>常用的管理功能</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Button @click="router.push('/admin/batches')" variant="outline" class="h-auto p-4 flex-col items-start">
|
||||||
|
<svg class="w-8 h-8 text-blue-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2h4l2 2h4a2 2 0 012 2v2M7 7V3a2 2 0 012-2h6a2 2 0 012 2v4"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="text-left">
|
||||||
|
<div class="font-medium">文件批次管理</div>
|
||||||
|
<div class="text-sm text-gray-500">查看和管理所有文件批次</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button @click="router.push('/admin/tokens')" variant="outline" class="h-auto p-4 flex-col items-start">
|
||||||
|
<svg class="w-8 h-8 text-green-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="text-left">
|
||||||
|
<div class="font-medium">API Token 管理</div>
|
||||||
|
<div class="text-sm text-gray-500">创建和管理 API 访问凭证</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button @click="refreshData" variant="outline" class="h-auto p-4 flex-col items-start">
|
||||||
|
<svg class="w-8 h-8 text-purple-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="text-left">
|
||||||
|
<div class="font-medium">刷新数据</div>
|
||||||
|
<div class="text-sm text-gray-500">更新系统统计信息</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast 组件 -->
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
// 组件导入
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
// API 和工具导入
|
||||||
|
import { adminApi, utils, type FileBatch } from '@/lib/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const loading = ref(true)
|
||||||
|
const recentBatches = ref<FileBatch[]>([])
|
||||||
|
const stats = ref({
|
||||||
|
totalBatches: 0,
|
||||||
|
activeBatches: 0,
|
||||||
|
expiredBatches: 0,
|
||||||
|
totalFiles: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取最近的批次数据
|
||||||
|
const response = await adminApi.getBatches({ page: 1, page_size: 10 })
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
const batches = response.data.data.data
|
||||||
|
recentBatches.value = batches
|
||||||
|
|
||||||
|
// 计算统计数据
|
||||||
|
stats.value.totalBatches = response.data.data.total
|
||||||
|
stats.value.activeBatches = batches.filter(batch => batch.status === 'active').length
|
||||||
|
stats.value.expiredBatches = batches.filter(batch => batch.status === 'expired').length
|
||||||
|
stats.value.totalFiles = batches.reduce((sum, batch) => sum + (batch.file_items?.length || 0), 0)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取数据失败:', error)
|
||||||
|
toast.error('获取数据失败,请重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
toast.info('正在刷新数据...')
|
||||||
|
await fetchData()
|
||||||
|
toast.success('数据已刷新')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
toast.success('已退出登录')
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusVariant = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'default'
|
||||||
|
case 'expired':
|
||||||
|
return 'secondary'
|
||||||
|
case 'deleted':
|
||||||
|
return 'destructive'
|
||||||
|
default:
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return '活跃'
|
||||||
|
case 'expired':
|
||||||
|
return '已过期'
|
||||||
|
case 'deleted':
|
||||||
|
return '已删除'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return utils.formatDate(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 自定义样式 */
|
||||||
|
.border-b-2 {
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航链接悬停效果 */
|
||||||
|
.hover\:border-gray-300:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-gray-700:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片悬停效果 */
|
||||||
|
.hover\:shadow-lg:hover {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮组悬停效果 */
|
||||||
|
.flex-col:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格行悬停效果 */
|
||||||
|
.hover\:bg-gray-50:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计卡片动画 */
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > * {
|
||||||
|
animation: slideInUp 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid > *:nth-child(1) { animation-delay: 0.1s; }
|
||||||
|
.grid > *:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.grid > *:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
.grid > *:nth-child(4) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
/* 响应式表格 */
|
||||||
|
.overflow-x-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(156, 163, 175, 0.7);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
258
src/views/admin/AdminLogin.vue
Normal file
258
src/views/admin/AdminLogin.vue
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<div class="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-indigo-100">
|
||||||
|
<svg class="h-8 w-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-bold text-gray-900">
|
||||||
|
管理员登录
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
请输入管理员密码以访问管理后台
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card class="border-0 shadow-lg">
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label for="password" class="text-sm font-medium text-gray-700">
|
||||||
|
管理员密码
|
||||||
|
</Label>
|
||||||
|
<div class="mt-1 relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
v-model="password"
|
||||||
|
placeholder="请输入管理员密码"
|
||||||
|
class="pr-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
>
|
||||||
|
<svg v-if="showPassword" class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L6.879 6.879a3 3 0 00-4.243 4.243M9.878 9.878a3 3 0 014.242 4.243M15.121 14.121L18.121 17.121a3 3 0 01-4.243 4.243M12 3c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!password || loading"
|
||||||
|
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||||
|
<svg class="animate-spin h-5 w-5 text-indigo-300" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ loading ? '登录中...' : '登录管理后台' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误信息显示 -->
|
||||||
|
<div v-if="error" class="rounded-md bg-red-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-red-800">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<div class="-mx-1.5 -my-1.5">
|
||||||
|
<button
|
||||||
|
@click="error = ''"
|
||||||
|
class="inline-flex bg-red-50 rounded-md p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 返回首页链接 -->
|
||||||
|
<div class="text-center">
|
||||||
|
<Button variant="link" @click="router.push('/')" class="text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast 组件 -->
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
// 组件导入
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
// API 导入
|
||||||
|
import { adminApi } from '@/lib/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const password = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!password.value.trim()) {
|
||||||
|
error.value = '请输入管理员密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await adminApi.login(password.value.trim())
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
// 保存 token
|
||||||
|
localStorage.setItem('admin_token', response.data.data.token)
|
||||||
|
|
||||||
|
toast.success('登录成功!')
|
||||||
|
|
||||||
|
// 跳转到管理后台
|
||||||
|
router.push('/admin')
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('登录失败:', err)
|
||||||
|
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
error.value = '密码错误,请重新输入'
|
||||||
|
} else {
|
||||||
|
error.value = err.response?.data?.msg || '登录失败,请重试'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空密码
|
||||||
|
password.value = ''
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已登录
|
||||||
|
const checkAuth = () => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token) {
|
||||||
|
// 如果已有 token,直接跳转到管理后台
|
||||||
|
router.replace('/admin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载
|
||||||
|
onMounted(() => {
|
||||||
|
checkAuth()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 自定义样式 */
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单焦点效果 */
|
||||||
|
.group:hover .group-hover\:text-indigo-400 {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框样式增强 */
|
||||||
|
input[type="password"]::-ms-reveal,
|
||||||
|
input[type="password"]::-ms-clear {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片阴影 */
|
||||||
|
.shadow-lg {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮悬停效果 */
|
||||||
|
.hover\:bg-indigo-700:hover {
|
||||||
|
background-color: #4338ca;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误消息动画 */
|
||||||
|
.bg-red-50 {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 聚焦环效果 */
|
||||||
|
.focus\:ring-2:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus\:ring-offset-2:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px #fff, 0 0 0 4px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
596
src/views/admin/BatchManagement.vue
Normal file
596
src/views/admin/BatchManagement.vue
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- 导航栏 -->
|
||||||
|
<nav class="bg-white border-b border-gray-200">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">文件批次管理</h1>
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:ml-6 md:flex md:space-x-8">
|
||||||
|
<router-link
|
||||||
|
to="/admin"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
概览
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/admin/batches"
|
||||||
|
class="border-indigo-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
文件管理
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/admin/tokens"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
API 管理
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<Button variant="outline" @click="router.push('/')" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-1a1 1 0 011-1h2a1 1 0 011 1v1a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
前往前台
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" @click="handleLogout" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||||
|
</svg>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 主要内容 -->
|
||||||
|
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<!-- 页面标题和搜索 -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="md:flex md:items-center md:justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">文件批次管理</h2>
|
||||||
|
<p class="mt-2 text-gray-600">管理系统中的所有文件批次</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex md:mt-0 md:ml-4">
|
||||||
|
<Button @click="refreshData" variant="outline" size="sm" class="mr-2">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选和搜索 -->
|
||||||
|
<Card class="mb-6">
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label for="status-filter">状态筛选</Label>
|
||||||
|
<Select v-model="filters.status">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="全部状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="active">活跃</SelectItem>
|
||||||
|
<SelectItem value="expired">已过期</SelectItem>
|
||||||
|
<SelectItem value="deleted">已删除</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<Label for="pickup-code">取件码搜索</Label>
|
||||||
|
<Input
|
||||||
|
id="pickup-code"
|
||||||
|
v-model="filters.pickupCode"
|
||||||
|
placeholder="输入取件码进行搜索..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<Button @click="searchBatches" class="mr-2">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" @click="clearFilters">
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 批次列表 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>批次列表</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
共 {{ pagination.total }} 个批次,当前第 {{ pagination.page }} 页
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<!-- Loading 状态 -->
|
||||||
|
<div v-if="loading" class="space-y-4">
|
||||||
|
<Skeleton class="h-4 w-full" />
|
||||||
|
<Skeleton class="h-4 w-3/4" />
|
||||||
|
<Skeleton class="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else-if="batches.length === 0" class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 text-lg">暂无批次数据</p>
|
||||||
|
<p class="text-gray-400 text-sm">尝试调整筛选条件或刷新页面</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 批次表格 -->
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>取件码</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>文件数量</TableHead>
|
||||||
|
<TableHead>下载次数</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead>过期时间</TableHead>
|
||||||
|
<TableHead class="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="batch in batches" :key="batch.id" class="hover:bg-gray-50">
|
||||||
|
<TableCell class="font-medium font-mono">
|
||||||
|
{{ batch.pickup_code }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ batch.type === 'text' ? '文本' : '文件' }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="getStatusVariant(batch.status)">
|
||||||
|
{{ getStatusText(batch.status) }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{{ batch.file_items?.length || (batch.type === 'text' ? 1 : 0) }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{{ batch.download_count }}{{ batch.max_downloads ? ` / ${batch.max_downloads}` : '' }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ formatDate(batch.created_at) }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span v-if="batch.expire_at">{{ formatDate(batch.expire_at) }}</span>
|
||||||
|
<span v-else class="text-gray-500">永不过期</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button variant="outline" size="sm" @click="showBatchDetail(batch)">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" @click="editBatch(batch)">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" @click="deleteBatch(batch)" :disabled="deleting.has(batch.id)">
|
||||||
|
<svg v-if="deleting.has(batch.id)" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div v-if="pagination.total > pagination.pageSize" class="flex items-center justify-between mt-6">
|
||||||
|
<div class="text-sm text-gray-700">
|
||||||
|
显示第 {{ (pagination.page - 1) * pagination.pageSize + 1 }} 到
|
||||||
|
{{ Math.min(pagination.page * pagination.pageSize, pagination.total) }} 条,
|
||||||
|
共 {{ pagination.total }} 条记录
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="changePage(pagination.page - 1)"
|
||||||
|
:disabled="pagination.page <= 1"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="changePage(pagination.page + 1)"
|
||||||
|
:disabled="pagination.page >= Math.ceil(pagination.total / pagination.pageSize)"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 批次详情对话框 -->
|
||||||
|
<Dialog :open="showDetailDialog" @update:open="showDetailDialog = $event">
|
||||||
|
<DialogContent class="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>批次详情</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
查看批次的详细信息
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div v-if="selectedBatch" class="py-4 space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p><span class="font-medium">取件码:</span> {{ selectedBatch.pickup_code }}</p>
|
||||||
|
<p><span class="font-medium">类型:</span> {{ selectedBatch.type === 'text' ? '文本' : '文件' }}</p>
|
||||||
|
<p><span class="font-medium">状态:</span>
|
||||||
|
<Badge :variant="getStatusVariant(selectedBatch.status)" class="ml-2">
|
||||||
|
{{ getStatusText(selectedBatch.status) }}
|
||||||
|
</Badge>
|
||||||
|
</p>
|
||||||
|
<p><span class="font-medium">下载次数:</span> {{ selectedBatch.download_count }}{{ selectedBatch.max_downloads ? ` / ${selectedBatch.max_downloads}` : '' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p><span class="font-medium">创建时间:</span> {{ formatDate(selectedBatch.created_at) }}</p>
|
||||||
|
<p><span class="font-medium">更新时间:</span> {{ formatDate(selectedBatch.updated_at) }}</p>
|
||||||
|
<p><span class="font-medium">过期时间:</span>
|
||||||
|
<span v-if="selectedBatch.expire_at">{{ formatDate(selectedBatch.expire_at) }}</span>
|
||||||
|
<span v-else class="text-gray-500">永不过期</span>
|
||||||
|
</p>
|
||||||
|
<p><span class="font-medium">过期类型:</span> {{ getExpireTypeText(selectedBatch.expire_type) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedBatch.remark">
|
||||||
|
<p class="font-medium mb-1">备注:</p>
|
||||||
|
<p class="text-gray-600 bg-gray-50 p-2 rounded">{{ selectedBatch.remark }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedBatch.type === 'text' && selectedBatch.content">
|
||||||
|
<p class="font-medium mb-1">文本内容:</p>
|
||||||
|
<div class="bg-gray-50 p-3 rounded max-h-40 overflow-y-auto">
|
||||||
|
<pre class="text-sm whitespace-pre-wrap">{{ selectedBatch.content }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedBatch.file_items && selectedBatch.file_items.length > 0">
|
||||||
|
<p class="font-medium mb-2">文件列表 ({{ selectedBatch.file_items.length }} 个文件):</p>
|
||||||
|
<div class="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
<div v-for="file in selectedBatch.file_items" :key="file.id" class="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ file.original_name }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ formatFileSize(file.size) }} • {{ file.mime_type }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button @click="showDetailDialog = false">
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Toast 组件 -->
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
// 组件导入
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
// API 和工具导入
|
||||||
|
import { adminApi, utils, type FileBatch } from '@/lib/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const loading = ref(true)
|
||||||
|
const batches = ref<FileBatch[]>([])
|
||||||
|
const selectedBatch = ref<FileBatch | null>(null)
|
||||||
|
const showDetailDialog = ref(false)
|
||||||
|
const deleting = ref(new Set<string>())
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
status: 'all',
|
||||||
|
pickupCode: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const fetchBatches = async () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page: pagination.page,
|
||||||
|
page_size: pagination.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status && filters.status !== 'all') {
|
||||||
|
params.status = filters.status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.pickupCode.trim()) {
|
||||||
|
params.pickup_code = filters.pickupCode.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await adminApi.getBatches(params)
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
batches.value = response.data.data.data
|
||||||
|
pagination.total = response.data.data.total
|
||||||
|
pagination.page = response.data.data.page
|
||||||
|
pagination.pageSize = response.data.data.page_size
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '获取批次列表失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取批次列表失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '获取批次列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchBatches = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
fetchBatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
filters.status = 'all'
|
||||||
|
filters.pickupCode = ''
|
||||||
|
pagination.page = 1
|
||||||
|
fetchBatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = () => {
|
||||||
|
fetchBatches()
|
||||||
|
toast.success('数据已刷新')
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePage = (page: number) => {
|
||||||
|
pagination.page = page
|
||||||
|
fetchBatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showBatchDetail = async (batch: FileBatch) => {
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getBatchDetail(batch.id)
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
selectedBatch.value = response.data.data
|
||||||
|
showDetailDialog.value = true
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('获取批次详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editBatch = (_batch: FileBatch) => {
|
||||||
|
// TODO: 实现批次编辑功能
|
||||||
|
toast.info('批次编辑功能开发中...')
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBatch = async (batch: FileBatch) => {
|
||||||
|
if (!confirm(`确定要删除批次 ${batch.pickup_code} 吗?此操作不可撤销。`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleting.value.add(batch.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await adminApi.deleteBatch(batch.id)
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
toast.success('批次删除成功')
|
||||||
|
fetchBatches()
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('删除批次失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '删除批次失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value.delete(batch.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
toast.success('已退出登录')
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusVariant = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'default'
|
||||||
|
case 'expired':
|
||||||
|
return 'secondary'
|
||||||
|
case 'deleted':
|
||||||
|
return 'destructive'
|
||||||
|
default:
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return '活跃'
|
||||||
|
case 'expired':
|
||||||
|
return '已过期'
|
||||||
|
case 'deleted':
|
||||||
|
return '已删除'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpireTypeText = (expireType: string) => {
|
||||||
|
switch (expireType) {
|
||||||
|
case 'time':
|
||||||
|
return '按时间'
|
||||||
|
case 'download':
|
||||||
|
return '按下载次数'
|
||||||
|
case 'permanent':
|
||||||
|
return '永久保存'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return utils.formatDate(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
return utils.formatFileSize(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载
|
||||||
|
onMounted(() => {
|
||||||
|
fetchBatches()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 导航样式 */
|
||||||
|
.border-b-2 {
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-gray-300:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-gray-700:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格行悬停效果 */
|
||||||
|
.hover\:bg-gray-50:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.overflow-x-auto,
|
||||||
|
.overflow-y-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar,
|
||||||
|
.overflow-y-auto::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-track,
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-thumb,
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-thumb:hover,
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(156, 163, 175, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区域动画 */
|
||||||
|
.space-y-4 > * {
|
||||||
|
animation: fadeInUp 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮悬停效果 */
|
||||||
|
.hover\:scale-105:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
651
src/views/admin/TokenManagement.vue
Normal file
651
src/views/admin/TokenManagement.vue
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- 导航栏 -->
|
||||||
|
<nav class="bg-white border-b border-gray-200">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">API Token 管理</h1>
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:ml-6 md:flex md:space-x-8">
|
||||||
|
<router-link
|
||||||
|
to="/admin"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
概览
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/admin/batches"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
文件管理
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/admin/tokens"
|
||||||
|
class="border-indigo-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
API 管理
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<Button variant="outline" @click="router.push('/')" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-1a1 1 0 011-1h2a1 1 0 011 1v1a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
前往前台
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" @click="handleLogout" size="sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||||
|
</svg>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 主要内容 -->
|
||||||
|
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<!-- 页面标题和操作 -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="md:flex md:items-center md:justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">API Token 管理</h2>
|
||||||
|
<p class="mt-2 text-gray-600">创建和管理 API 访问凭证</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex md:mt-0 md:ml-4">
|
||||||
|
<Button @click="refreshData" variant="outline" size="sm" class="mr-2">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button @click="showCreateDialog = true">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
创建 Token
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Token 说明 -->
|
||||||
|
<Card class="mb-6 border-blue-200 bg-blue-50">
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800">API Token 使用说明</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<li>API Token 用于程序化访问文件中转站的接口</li>
|
||||||
|
<li>Token 创建后只显示一次,请妥善保存</li>
|
||||||
|
<li>可以设置 Token 的权限范围和过期时间</li>
|
||||||
|
<li>被撤销的 Token 会立即失效,但记录会保留</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Token 列表 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>API Token 列表</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
管理系统中的 API 访问凭证
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<!-- Loading 状态 -->
|
||||||
|
<div v-if="loading" class="space-y-4">
|
||||||
|
<Skeleton class="h-4 w-full" />
|
||||||
|
<Skeleton class="h-4 w-3/4" />
|
||||||
|
<Skeleton class="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else-if="tokens.length === 0" class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 text-lg">暂无 API Token</p>
|
||||||
|
<p class="text-gray-400 text-sm">点击上方按钮创建第一个 Token</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token 表格 -->
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>权限范围</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead>最后使用</TableHead>
|
||||||
|
<TableHead>过期时间</TableHead>
|
||||||
|
<TableHead class="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="token in tokens" :key="token.id" class="hover:bg-gray-50">
|
||||||
|
<TableCell class="font-medium">
|
||||||
|
{{ token.name }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ token.scope || 'ALL' }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="token.revoked ? 'destructive' : 'default'">
|
||||||
|
{{ token.revoked ? '已撤销' : '活跃' }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ formatDate(token.created_at) }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span v-if="token.last_used_at">{{ formatDate(token.last_used_at) }}</span>
|
||||||
|
<span v-else class="text-gray-500">从未使用</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span v-if="token.expire_at">{{ formatDate(token.expire_at) }}</span>
|
||||||
|
<span v-else class="text-gray-500">永不过期</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
v-if="!token.revoked"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="revokeToken(token)"
|
||||||
|
:disabled="revoking.has(token.id)"
|
||||||
|
>
|
||||||
|
<svg v-if="revoking.has(token.id)" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728"></path>
|
||||||
|
</svg>
|
||||||
|
撤销
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
@click="deleteToken(token)"
|
||||||
|
:disabled="deleting.has(token.id)"
|
||||||
|
>
|
||||||
|
<svg v-if="deleting.has(token.id)" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建 Token 对话框 -->
|
||||||
|
<Dialog :open="showCreateDialog" @update:open="showCreateDialog = $event">
|
||||||
|
<DialogContent class="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>创建 API Token</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
创建新的 API 访问凭证
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form @submit.prevent="createToken" class="py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label for="token-name">Token 名称 *</Label>
|
||||||
|
<Input
|
||||||
|
id="token-name"
|
||||||
|
v-model="createForm.name"
|
||||||
|
placeholder="为这个 Token 起一个名字..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="token-scope">权限范围</Label>
|
||||||
|
<Select v-model="createForm.scope">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择权限范围" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部权限</SelectItem>
|
||||||
|
<SelectItem value="upload">仅上传</SelectItem>
|
||||||
|
<SelectItem value="pickup">仅下载</SelectItem>
|
||||||
|
<SelectItem value="upload,pickup">上传和下载</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
限制此 Token 可以访问的功能范围
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="token-expire">过期时间</Label>
|
||||||
|
<Select v-model="createForm.expireType">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择过期策略" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="never">永不过期</SelectItem>
|
||||||
|
<SelectItem value="7">7 天后过期</SelectItem>
|
||||||
|
<SelectItem value="30">30 天后过期</SelectItem>
|
||||||
|
<SelectItem value="90">90 天后过期</SelectItem>
|
||||||
|
<SelectItem value="365">1 年后过期</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showCreateDialog = false">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button @click="createToken" :disabled="creating || !createForm.name.trim()">
|
||||||
|
<svg v-if="creating" class="animate-spin -ml-1 mr-3 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ creating ? '创建中...' : '创建 Token' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Token 创建成功对话框 -->
|
||||||
|
<Dialog :open="showTokenDialog" @update:open="showTokenDialog = $event">
|
||||||
|
<DialogContent class="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="text-green-600">Token 创建成功</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请妥善保存以下 Token,它只会显示一次
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div v-if="newToken" class="py-4">
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Token 名称</p>
|
||||||
|
<p class="font-medium">{{ newToken.data.name }}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{{ newToken.data.scope || 'ALL' }}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-600">API Token</p>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<code class="flex-1 p-2 bg-white border rounded text-sm font-mono break-all">{{ newToken.token }}</code>
|
||||||
|
<Button @click="copyToken" variant="outline" size="sm">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="newToken.data.expire_at" class="mt-3 text-sm text-gray-600">
|
||||||
|
过期时间: {{ formatDate(newToken.data.expire_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-yellow-800">重要提醒</p>
|
||||||
|
<p class="text-sm text-yellow-700 mt-1">
|
||||||
|
此 Token 只显示一次,关闭对话框后将无法再次查看。请确保已妥善保存。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button @click="closeTokenDialog">
|
||||||
|
我已保存,关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Toast 组件 -->
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
// 组件导入
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
// API 和工具导入
|
||||||
|
import { adminApi, utils, type APIToken } from '@/lib/api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const loading = ref(true)
|
||||||
|
const tokens = ref<APIToken[]>([])
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showTokenDialog = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const revoking = ref(new Set<number>())
|
||||||
|
const deleting = ref(new Set<number>())
|
||||||
|
|
||||||
|
const createForm = reactive({
|
||||||
|
name: '',
|
||||||
|
scope: 'all',
|
||||||
|
expireType: 'never'
|
||||||
|
})
|
||||||
|
|
||||||
|
const newToken = ref<{ token: string; data: APIToken } | null>(null)
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const fetchTokens = async () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await adminApi.getTokens()
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
tokens.value = response.data.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '获取 Token 列表失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取 Token 列表失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '获取 Token 列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createToken = async () => {
|
||||||
|
if (!createForm.name.trim()) {
|
||||||
|
toast.warning('请输入 Token 名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: any = {
|
||||||
|
name: createForm.name.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createForm.scope && createForm.scope !== 'all') {
|
||||||
|
data.scope = createForm.scope
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createForm.expireType && createForm.expireType !== 'never') {
|
||||||
|
const days = parseInt(createForm.expireType)
|
||||||
|
const expireAt = new Date()
|
||||||
|
expireAt.setDate(expireAt.getDate() + days)
|
||||||
|
data.expire_at = expireAt.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await adminApi.createToken(data)
|
||||||
|
|
||||||
|
if (response.data.code === 201) {
|
||||||
|
newToken.value = response.data.data
|
||||||
|
showCreateDialog.value = false
|
||||||
|
showTokenDialog.value = true
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
createForm.name = ''
|
||||||
|
createForm.scope = 'all'
|
||||||
|
createForm.expireType = 'never'
|
||||||
|
|
||||||
|
// 刷新列表
|
||||||
|
fetchTokens()
|
||||||
|
|
||||||
|
toast.success('Token 创建成功')
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || 'Token 创建失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('创建 Token 失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '创建 Token 失败')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeToken = async (token: APIToken) => {
|
||||||
|
if (!confirm(`确定要撤销 Token "${token.name}" 吗?撤销后将立即失效。`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
revoking.value.add(token.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await adminApi.revokeToken(token.id)
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
toast.success('Token 已撤销')
|
||||||
|
fetchTokens()
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '撤销失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('撤销 Token 失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '撤销 Token 失败')
|
||||||
|
} finally {
|
||||||
|
revoking.value.delete(token.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteToken = async (token: APIToken) => {
|
||||||
|
if (!confirm(`确定要删除 Token "${token.name}" 吗?此操作不可撤销。`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleting.value.add(token.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await adminApi.deleteToken(token.id)
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
toast.success('Token 删除成功')
|
||||||
|
fetchTokens()
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.msg || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('删除 Token 失败:', error)
|
||||||
|
toast.error(error.response?.data?.msg || '删除 Token 失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value.delete(token.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToken = async () => {
|
||||||
|
if (newToken.value?.token) {
|
||||||
|
const success = await utils.copyToClipboard(newToken.value.token)
|
||||||
|
if (success) {
|
||||||
|
toast.success('Token 已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
toast.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTokenDialog = () => {
|
||||||
|
showTokenDialog.value = false
|
||||||
|
newToken.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = () => {
|
||||||
|
fetchTokens()
|
||||||
|
toast.success('数据已刷新')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
toast.success('已退出登录')
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return utils.formatDate(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTokens()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 导航样式 */
|
||||||
|
.border-b-2 {
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-gray-300:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-gray-700:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格行悬停效果 */
|
||||||
|
.hover\:bg-gray-50:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块样式 */
|
||||||
|
code {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.overflow-x-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(156, 163, 175, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容动画 */
|
||||||
|
.space-y-4 > * {
|
||||||
|
animation: fadeInUp 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 警告框样式 */
|
||||||
|
.bg-yellow-50 {
|
||||||
|
animation: slideInDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 成功提示样式 */
|
||||||
|
.bg-blue-50 {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user