基础功能完成并进行错误修复

This commit is contained in:
2026-01-14 13:48:05 +08:00
parent e1fcf74b66
commit 45101e37c1
21 changed files with 4828 additions and 19 deletions

6
.env Normal file
View File

@@ -0,0 +1,6 @@
# API 基础地址
VITE_API_URL=http://localhost:8080
# 应用配置
VITE_APP_TITLE=文件中转站
VITE_APP_DESCRIPTION=安全便捷的文件暂存服务

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
# 开发环境配置
VITE_API_URL=http://localhost:8080

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
# 生产环境配置
VITE_API_URL=/api

66
OPTIMIZATION.md Normal file
View 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
View 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
View 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. **流畅的交互体验**:加载状态、进度显示、即时反馈
界面现在更加现代化和用户友好!🎉

View File

@@ -284,8 +284,8 @@
"summary": "获取批次详情",
"parameters": [
{
"type": "integer",
"description": "批次 ID",
"type": "string",
"description": "批次 ID (UUID)",
"name": "batch_id",
"in": "path",
"required": true
@@ -337,8 +337,8 @@
"summary": "修改批次信息",
"parameters": [
{
"type": "integer",
"description": "批次 ID",
"type": "string",
"description": "批次 ID (UUID)",
"name": "batch_id",
"in": "path",
"required": true
@@ -396,8 +396,8 @@
"summary": "删除批次",
"parameters": [
{
"type": "integer",
"description": "批次 ID",
"type": "string",
"description": "批次 ID (UUID)",
"name": "batch_id",
"in": "path",
"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": {
"post": {
"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": {
"get": {
"description": "根据文件 ID 下载单个文件",
@@ -703,8 +811,8 @@
"summary": "下载单个文件",
"parameters": [
{
"type": "integer",
"description": "文件 ID",
"type": "string",
"description": "文件 ID (UUID)",
"name": "file_id",
"in": "path",
"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": {
"type": "object",
"properties": {
@@ -876,7 +1127,7 @@
}
},
"id": {
"type": "integer"
"type": "string"
},
"max_downloads": {
"type": "integer"
@@ -904,13 +1155,13 @@
"type": "object",
"properties": {
"batch_id": {
"type": "integer"
"type": "string"
},
"created_at": {
"type": "string"
},
"id": {
"type": "integer"
"type": "string"
},
"mime_type": {
"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": {
"type": "object",
"properties": {
"batch_id": {
"type": "integer"
"type": "string"
},
"expire_at": {
"type": "string"

302
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.1.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0",
@@ -18,6 +19,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.24",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
@@ -1391,6 +1393,12 @@
"@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": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.2.tgz",
@@ -1545,6 +1553,36 @@
"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": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -1566,6 +1604,18 @@
"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": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1578,6 +1628,15 @@
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1587,6 +1646,20 @@
"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": {
"version": "5.18.4",
"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"
}
},
"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": {
"version": "0.27.2",
"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": {
"version": "2.3.3",
"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_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": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"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": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1972,6 +2223,36 @@
"@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": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -2056,6 +2337,12 @@
"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": {
"version": "2.7.0",
"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": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz",

View File

@@ -1,17 +1,21 @@
{
"name": "vue-vite-shadcn-template",
"name": "file-relay-ui",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"description": "文件中转站前端应用",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"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": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.1.0",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0",
@@ -19,6 +23,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.24",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},
"devDependencies": {

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
import ComponentShowcase from './views/ComponentShowcase.vue'
</script>
<template>
<div id="app">
<ComponentShowcase />
<router-view />
</div>
</template>

17
src/env.d.ts vendored Normal file
View 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
View 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,
}

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import './style.css'
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
View 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
View 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
View 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
View 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>

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

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

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

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