界面优化调整,及完善

This commit is contained in:
2026-01-14 16:29:46 +08:00
parent 45101e37c1
commit 657d9d3e37
23 changed files with 1528 additions and 306 deletions

View File

@@ -24,7 +24,7 @@
"AdminAuth": []
}
],
"description": "获取系统中所有 API Token 的详信息(不包含哈希)",
"description": "获取系统中所有 API Token 的详信息(不包含哈希)",
"produces": [
"application/json"
],
@@ -431,18 +431,29 @@
"application/json"
],
"tags": [
"管理员",
"配置"
"Admin"
],
"summary": "获取完整配置",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/model.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/config.Config"
}
}
}
]
}
}
}
},
"put": {
"security": [
@@ -458,8 +469,7 @@
"application/json"
],
"tags": [
"管理员",
"配置"
"Admin"
],
"summary": "更新配置",
"parameters": [
@@ -477,7 +487,19 @@
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/model.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/config.Config"
}
}
}
]
}
},
"400": {
@@ -549,7 +571,12 @@
},
"/api/batches": {
"post": {
"description": "上传一个或多个文件并创建一个提取批次",
"security": [
{
"APITokenAuth": []
}
],
"description": "上传一个或多个文件并创建一个提取批次。如果配置了 require_token则必须提供带 upload scope 的 API Token。",
"consumes": [
"multipart/form-data"
],
@@ -629,7 +656,12 @@
},
"/api/batches/text": {
"post": {
"description": "中转一段长文本内容并创建一个提取批次",
"security": [
{
"APITokenAuth": []
}
],
"description": "中转一段长文本内容并创建一个提取批次。如果配置了 require_token则必须提供带 upload scope 的 API Token。",
"consumes": [
"application/json"
],
@@ -687,7 +719,12 @@
},
"/api/batches/{pickup_code}": {
"get": {
"description": "根据取件码获取文件批次详详情和文件列表",
"security": [
{
"APITokenAuth": []
}
],
"description": "根据取件码获取文件批次详细信息和文件列表。可选提供带 pickup scope 的 API Token。",
"produces": [
"application/json"
],
@@ -734,7 +771,12 @@
},
"/api/batches/{pickup_code}/download": {
"get": {
"description": "根据取件码将批次内的所有文件打包为 ZIP 格式一次性下载",
"security": [
{
"APITokenAuth": []
}
],
"description": "根据取件码将批次内的所有文件打包为 ZIP 格式一次性下载。可选提供带 pickup scope 的 API Token。",
"produces": [
"application/zip"
],
@@ -774,7 +816,7 @@
"application/json"
],
"tags": [
"公共"
"Public"
],
"summary": "获取公共配置",
"responses": {
@@ -801,7 +843,12 @@
},
"/api/files/{file_id}/download": {
"get": {
"description": "根据文件 ID 下载单个文件",
"security": [
{
"APITokenAuth": []
}
],
"description": "根据文件 ID 下载单个文件。可选提供带 pickup scope 的 API Token。",
"produces": [
"application/octet-stream"
],
@@ -935,13 +982,16 @@
"config.APITokenConfig": {
"type": "object",
"properties": {
"allowAdminAPI": {
"allow_admin_api": {
"description": "是否允许 API Token 访问管理接口",
"type": "boolean"
},
"enabled": {
"description": "是否启用 API Token",
"type": "boolean"
},
"maxTokens": {
"max_tokens": {
"description": "最大 Token 数量",
"type": "integer"
}
}
@@ -949,30 +999,61 @@
"config.Config": {
"type": "object",
"properties": {
"apitoken": {
"api_token": {
"description": "API Token 设置",
"allOf": [
{
"$ref": "#/definitions/config.APITokenConfig"
}
]
},
"database": {
"description": "数据库设置",
"allOf": [
{
"$ref": "#/definitions/config.DatabaseConfig"
}
]
},
"security": {
"description": "安全设置",
"allOf": [
{
"$ref": "#/definitions/config.SecurityConfig"
}
]
},
"site": {
"description": "站点设置",
"allOf": [
{
"$ref": "#/definitions/config.SiteConfig"
}
]
},
"storage": {
"description": "存储设置",
"allOf": [
{
"$ref": "#/definitions/config.StorageConfig"
}
]
},
"upload": {
"description": "上传设置",
"allOf": [
{
"$ref": "#/definitions/config.UploadConfig"
}
]
}
}
},
"config.DatabaseConfig": {
"type": "object",
"properties": {
"path": {
"description": "数据库文件路径",
"type": "string"
}
}
@@ -980,16 +1061,20 @@
"config.SecurityConfig": {
"type": "object",
"properties": {
"adminPasswordHash": {
"admin_password_hash": {
"description": "管理员密码哈希 (bcrypt)",
"type": "string"
},
"jwtsecret": {
"jwt_secret": {
"description": "JWT 签名密钥",
"type": "string"
},
"pickupCodeLength": {
"pickup_code_length": {
"description": "取件码长度",
"type": "integer"
},
"pickupFailLimit": {
"pickup_fail_limit": {
"description": "取件失败尝试限制",
"type": "integer"
}
}
@@ -998,9 +1083,11 @@
"type": "object",
"properties": {
"description": {
"description": "站点描述",
"type": "string"
},
"name": {
"description": "站点名称",
"type": "string"
}
}
@@ -1012,6 +1099,7 @@
"type": "object",
"properties": {
"path": {
"description": "本地存储路径",
"type": "string"
}
}
@@ -1019,42 +1107,53 @@
"s3": {
"type": "object",
"properties": {
"accessKey": {
"access_key": {
"description": "S3 Access Key",
"type": "string"
},
"bucket": {
"description": "S3 Bucket",
"type": "string"
},
"endpoint": {
"description": "S3 端点",
"type": "string"
},
"region": {
"description": "S3 区域",
"type": "string"
},
"secretKey": {
"secret_key": {
"description": "S3 Secret Key",
"type": "string"
},
"useSSL": {
"use_ssl": {
"description": "是否使用 SSL",
"type": "boolean"
}
}
},
"type": {
"description": "存储类型: local, webdav, s3",
"type": "string"
},
"webDAV": {
"webdav": {
"type": "object",
"properties": {
"password": {
"description": "WebDAV 密码",
"type": "string"
},
"root": {
"description": "WebDAV 根目录",
"type": "string"
},
"url": {
"description": "WebDAV 地址",
"type": "string"
},
"username": {
"description": "WebDAV 用户名",
"type": "string"
}
}
@@ -1064,14 +1163,21 @@
"config.UploadConfig": {
"type": "object",
"properties": {
"maxBatchFiles": {
"max_batch_files": {
"description": "每个批次最大文件数",
"type": "integer"
},
"maxFileSizeMB": {
"max_file_size_mb": {
"description": "单个文件最大大小 (MB)",
"type": "integer"
},
"maxRetentionDays": {
"max_retention_days": {
"description": "最大保留天数",
"type": "integer"
},
"require_token": {
"description": "是否强制要求上传 Token",
"type": "boolean"
}
}
},
@@ -1223,10 +1329,7 @@
}
}
},
"public.PublicConfig": {
"type": "object",
"properties": {
"api_token": {
"public.PublicAPITokenConfig": {
"type": "object",
"properties": {
"enabled": {
@@ -1234,14 +1337,42 @@
}
}
},
"public.PublicConfig": {
"type": "object",
"properties": {
"api_token": {
"$ref": "#/definitions/public.PublicAPITokenConfig"
},
"security": {
"$ref": "#/definitions/public.PublicSecurityConfig"
},
"site": {
"$ref": "#/definitions/config.SiteConfig"
},
"storage": {
"$ref": "#/definitions/public.PublicStorageConfig"
},
"upload": {
"$ref": "#/definitions/config.UploadConfig"
}
}
},
"public.PublicSecurityConfig": {
"type": "object",
"properties": {
"pickup_code_length": {
"type": "integer"
}
}
},
"public.PublicStorageConfig": {
"type": "object",
"properties": {
"type": {
"type": "string"
}
}
},
"public.UploadResponse": {
"type": "object",
"properties": {
@@ -1286,8 +1417,14 @@
}
},
"securityDefinitions": {
"APITokenAuth": {
"description": "Type \"Bearer \u003cAPI-Token\u003e\" to authenticate. Required scope depends on the endpoint.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"AdminAuth": {
"description": "Type \"Bearer \u003cyour-jwt-token\u003e\" to authenticate.",
"description": "Type \"Bearer \u003cJWT-Token\u003e\" or \"Bearer \u003cAPI-Token\u003e\" to authenticate. API Token must have 'admin' scope.",
"type": "apiKey",
"name": "Authorization",
"in": "header"

View File

@@ -0,0 +1,94 @@
# FileRelay 配置项详细说明文档
本文档整理了 FileRelay 系统 `config.yaml` 配置文件中各字段的含义、类型及示例,供前端配置页面开发参考。
## 1. 站点设置 (site)
用于定义前端展示的站点基本信息。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `name` | string | 站点名称,显示在网页标题和页头 | `文件暂存柜` |
| `description` | string | 站点描述,显示在首页或元标签中 | `临时文件中转服务` |
## 2. 安全设置 (security)
涉及系统鉴权、取件保护相关的核心安全配置。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `admin_password_hash` | string | 管理员密码的 bcrypt 哈希值 | `$2a$10$...` |
| `pickup_code_length` | int | 自动生成的取件码长度(已包含在公共配置接口中) | `6` |
| `pickup_fail_limit` | int | 单个 IP 对单个取件码尝试失败的最大次数,超过后将被临时封禁 | `5` |
| `jwt_secret` | string | 用于签发管理端 JWT Token 的密钥,建议设置为复杂随机字符串 | `file-relay-secret` |
## 3. 上传设置 (upload)
控制文件上传的限制和策略。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `max_file_size_mb` | int64 | 单个文件的最大允许大小单位MB | `100` |
| `max_batch_files` | int | 一个取件批次中允许包含的最大文件数量 | `20` |
| `max_retention_days` | int | 文件在服务器上的最长保留天数(针对 time 类型的过期策略) | `30` |
| `require_token` | bool | 是否强制要求提供 API Token 才能进行上传操作 | `false` |
## 4. 存储设置 (storage)
定义文件的实际物理存储方式。系统支持多种存储后端。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `type` | string | 当前激活的存储类型。可选值:`local`, `webdav`, `s3` | `local` |
### 4.1 本地存储 (local)
`type``local` 时生效。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `path` | string | 文件存储在服务器本地的相对或绝对路径 | `storage_data` |
### 4.2 WebDAV 存储 (webdav)
`type``webdav` 时生效。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `url` | string | WebDAV 服务器的 API 地址 | `https://dav.example.com` |
| `username` | string | WebDAV 登录用户名 | `user` |
| `password` | string | WebDAV 登录密码 | `pass` |
| `root` | string | WebDAV 上的基础存储根目录 | `/file-relay` |
### 4.3 S3 存储 (s3)
`type``s3` 时生效。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `endpoint` | string | S3 服务端点 | `s3.amazonaws.com` |
| `region` | string | S3 区域 | `us-east-1` |
| `access_key` | string | S3 Access Key | `your-access-key` |
| `secret_key` | string | S3 Secret Key | `your-secret-key` |
| `bucket` | string | S3 存储桶名称 | `file-relay-bucket` |
| `use_ssl` | bool | 是否强制使用 SSL (HTTPS) 连接 | `false` |
## 5. API Token 设置 (api_token)
控制系统对外开放的 API Token 管理功能。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `enabled` | bool | 是否启用 API Token 功能模块 | `true` |
| `allow_admin_api` | bool | 是否允许具备 `admin` 权限的 API Token 访问管理接口 | `true` |
| `max_tokens` | int | 系统允许创建的 API Token 最大总数限制 | `20` |
## 6. 数据库设置 (database)
系统元数据存储配置。
| 字段名 | 类型 | 含义 | 示例 |
| :--- | :--- | :--- | :--- |
| `path` | string | SQLite 数据库文件的路径 | `file_relay.db` |
84:
85:---
86:
87:## 附录:公共配置接口 (/api/config)
88:
89:为了方便前端展示和交互约束,系统提供了 `/api/config` 接口,该接口不需要鉴权,返回以下非敏感字段:
90:
91:- **site**: 完整内容(`name`, `description`
92:- **upload**: 完整内容(`max_file_size_mb`, `max_batch_files`, `max_retention_days`, `require_token`
93:- **api_token**: 仅包含 `enabled` 开关
94:- **pickup_code_length**: 核心字段,用于前端生成固定位数的取件码输入框。

46
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "vue-vite-shadcn-template",
"version": "0.0.0",
"name": "file-relay-ui",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vue-vite-shadcn-template",
"version": "0.0.0",
"name": "file-relay-ui",
"version": "1.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
@@ -19,6 +19,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.24",
"vue-input-otp": "^0.3.2",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},
@@ -2625,6 +2626,43 @@
}
}
},
"node_modules/vue-input-otp": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/vue-input-otp/-/vue-input-otp-0.3.2.tgz",
"integrity": "sha512-QMl1842WB6uNAsK4+mZXIskb00TOfahH3AQt8rpRecbtQnOp+oHSUbL/Z3wekfy6pAl+hyN3e1rCUSkCMzbDLQ==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "^12.8.2",
"reka-ui": "^2.6.1"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/vue-input-otp/node_modules/@vueuse/core": {
"version": "12.8.2",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz",
"integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "12.8.2",
"@vueuse/shared": "12.8.2",
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/vue-input-otp/node_modules/@vueuse/metadata": {
"version": "12.8.2",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz",
"integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",

View File

@@ -23,6 +23,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.24",
"vue-input-otp": "^0.3.2",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},

View File

@@ -0,0 +1,117 @@
<template>
<nav class="bg-white/95 backdrop-blur-sm border-b border-gray-200 shadow-sm">
<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="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="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="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>
<router-link
to="/admin/config"
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
:class="$route.path.includes('/admin/config') ? 'border-indigo-500 text-gray-900' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'"
>
系统配置
</router-link>
</div>
</div>
<div class="flex items-center space-x-4">
<!-- 移动端菜单按钮 -->
<Button variant="ghost" @click="showMobileMenu = !showMobileMenu" class="md:hidden" size="sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</Button>
<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 class="md:hidden" v-if="showMobileMenu">
<div class="pt-2 pb-3 space-y-1">
<router-link
to="/admin"
class="block pl-3 pr-4 py-2 text-base font-medium"
:class="$route.path === '/admin' ? 'bg-indigo-50 border-indigo-500 text-indigo-700 border-l-4' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'"
@click="showMobileMenu = false"
>
概览
</router-link>
<router-link
to="/admin/batches"
class="block pl-3 pr-4 py-2 text-base font-medium"
:class="$route.path.includes('/admin/batches') ? 'bg-indigo-50 border-indigo-500 text-indigo-700 border-l-4' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'"
@click="showMobileMenu = false"
>
文件管理
</router-link>
<router-link
to="/admin/tokens"
class="block pl-3 pr-4 py-2 text-base font-medium"
:class="$route.path.includes('/admin/tokens') ? 'bg-indigo-50 border-indigo-500 text-indigo-700 border-l-4' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'"
@click="showMobileMenu = false"
>
API 管理
</router-link>
<router-link
to="/admin/config"
class="block pl-3 pr-4 py-2 text-base font-medium"
:class="$route.path.includes('/admin/config') ? 'bg-indigo-50 border-indigo-500 text-indigo-700 border-l-4' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'"
@click="showMobileMenu = false"
>
系统配置
</router-link>
</div>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
const router = useRouter()
const showMobileMenu = ref(false)
const handleLogout = () => {
localStorage.removeItem('admin_token')
router.push('/admin/login')
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<nav class="bg-white border-b border-gray-200 sticky top-0 z-10">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center h-16">
<!-- 左侧站点信息 -->
<div class="flex items-center space-x-4">
<router-link to="/" class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" 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>
<h1 class="text-lg font-semibold text-gray-900">{{ publicConfig.config.site?.name || '文件中转站' }}</h1>
<p v-if="showDescription" class="text-xs text-gray-500">{{ publicConfig.config.site?.description || '安全便捷的文件暂存服务' }}</p>
</div>
</router-link>
</div>
<!-- 右侧导航链接 -->
<div class="flex items-center space-x-1">
<router-link to="/">
<Button variant="ghost" size="sm" :class="$route.path === '/' ? 'bg-gray-100' : ''">
<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 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5l4-4 4 4"></path>
</svg>
取件
</Button>
</router-link>
<router-link to="/upload">
<Button variant="ghost" size="sm" :class="$route.path === '/upload' ? 'bg-gray-100' : ''">
<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="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>
</router-link>
<!-- 管理员入口 -->
<router-link v-if="!isAdminRoute" to="/admin" class="ml-4">
<Button 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
管理
</Button>
</router-link>
</div>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { usePublicConfig } from '@/composables/usePublicConfig'
import { Button } from '@/components/ui/button'
defineProps<{
showDescription?: boolean
}>()
const route = useRoute()
const publicConfig = usePublicConfig()
const isAdminRoute = computed(() => route.path.includes('/admin'))
</script>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { OTPInputEmits, OTPInputProps } from "vue-input-otp"
import { reactiveOmit } from "@vueuse/core"
import { useForwardPropsEmits } from "reka-ui"
import { OTPInput } from "vue-input-otp"
import { cn } from "@/lib/utils"
const props = defineProps<OTPInputProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<OTPInputEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<OTPInput
v-slot="slotProps"
v-bind="forwarded"
:container-class="cn('flex items-center gap-2 has-disabled:opacity-50', props.class)"
data-slot="input-otp"
class="disabled:cursor-not-allowed"
>
<slot v-bind="slotProps" />
</OTPInput>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<div
data-slot="input-otp-group"
v-bind="forwarded"
:class="cn('flex items-center', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { MinusIcon } from "lucide-vue-next"
import { useForwardProps } from "reka-ui"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const forwarded = useForwardProps(props)
</script>
<template>
<div
data-slot="input-otp-separator"
role="separator"
v-bind="forwarded"
>
<slot>
<MinusIcon />
</slot>
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { useForwardProps } from "reka-ui"
import { computed } from "vue"
import { useVueOTPContext } from "vue-input-otp"
import { cn } from "@/lib/utils"
const props = defineProps<{ index: number, class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
const context = useVueOTPContext()
const slot = computed(() => context?.value.slots[props.index])
</script>
<template>
<div
v-bind="forwarded"
data-slot="input-otp-slot"
:data-active="slot?.isActive"
:class="cn('data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]', props.class)"
>
{{ slot?.char }}
<div v-if="slot?.hasFakeCaret" class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
</div>
</template>

View File

@@ -0,0 +1,4 @@
export { default as InputOTP } from "./InputOTP.vue"
export { default as InputOTPGroup } from "./InputOTPGroup.vue"
export { default as InputOTPSeparator } from "./InputOTPSeparator.vue"
export { default as InputOTPSlot } from "./InputOTPSlot.vue"

View File

@@ -0,0 +1,142 @@
import { ref, onMounted } from 'vue'
import { publicApi, type PublicConfig } from '@/lib/api'
// 默认配置
const defaultConfig: PublicConfig = {
site: {
name: '文件中转站',
description: '安全、便捷的文件暂存服务'
},
upload: {
max_file_size_mb: 100,
max_batch_files: 10,
max_retention_days: 30,
require_token: false
},
security: {
pickup_code_length: 6
},
storage: {
type: 'local'
},
api_token: {
enabled: false
}
}
// 全局配置状态
const config = ref<PublicConfig>(defaultConfig)
const loading = ref(false)
export function usePublicConfig() {
// 加载公共配置
const loadConfig = async () => {
try {
loading.value = true
const response = await publicApi.getPublicConfig()
config.value = response.data.data
} catch (error) {
console.error('加载公共配置失败:', error)
// 使用默认配置
config.value = defaultConfig
} finally {
loading.value = false
}
}
// 获取上传配置
const getUploadLimits = () => {
return {
maxFileSizeMB: config.value.upload.max_file_size_mb,
maxFileSize: config.value.upload.max_file_size_mb * 1024 * 1024, // 字节
maxBatchFiles: config.value.upload.max_batch_files,
maxRetentionDays: config.value.upload.max_retention_days
}
}
// 生成失效时间选项
const getExpireOptions = () => {
const maxDays = config.value.upload.max_retention_days
const options = [
{ label: '1小时', value: 1/24 },
{ label: '6小时', value: 6/24 },
{ label: '1天', value: 1 },
{ label: '3天', value: 3 },
{ label: '7天', value: 7 }
]
// 只显示不超过最大保存天数的选项
const validOptions = options.filter(option => option.value <= maxDays)
// 如果最大天数大于7添加更多选项
if (maxDays >= 14) {
validOptions.push({ label: '14天', value: 14 })
}
if (maxDays >= 30) {
validOptions.push({ label: '30天', value: 30 })
}
if (maxDays >= 60) {
validOptions.push({ label: '60天', value: 60 })
}
if (maxDays >= 90) {
validOptions.push({ label: '90天', value: 90 })
}
// 如果允许,添加永久选项
validOptions.push({ label: '永久', value: 0 })
return validOptions
}
// 生成下载次数选项
const getDownloadOptions = () => {
return [
{ label: '1次', value: 1 },
{ label: '3次', value: 3 },
{ label: '5次', value: 5 },
{ label: '10次', value: 10 },
{ label: '20次', value: 20 },
{ label: '50次', value: 50 },
{ label: '不限制', value: 0 }
]
}
// 验证文件大小
const validateFileSize = (file: File): boolean => {
const limits = getUploadLimits()
return file.size <= limits.maxFileSize
}
// 验证文件数量
const validateFileCount = (files: FileList | File[]): boolean => {
const limits = getUploadLimits()
return files.length <= limits.maxBatchFiles
}
// 格式化文件大小限制文本
const getFileSizeLimit = (): string => {
const sizeMB = config.value.upload.max_file_size_mb
if (sizeMB >= 1024) {
return `${(sizeMB / 1024).toFixed(1)}GB`
}
return `${sizeMB}MB`
}
return {
config,
loading,
loadConfig,
getUploadLimits,
getExpireOptions,
getDownloadOptions,
validateFileSize,
validateFileCount,
getFileSizeLimit
}
}
// 初始化配置(应用启动时调用)
export async function initPublicConfig() {
const { loadConfig } = usePublicConfig()
await loadConfig()
}

View File

@@ -0,0 +1,19 @@
<template>
<div class="h-screen flex flex-col bg-gray-50">
<!-- 固定导航栏 -->
<AdminNavBar />
<!-- 内容区域 - 独立滚动 -->
<div class="flex-1 overflow-auto">
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AdminNavBar from '@/components/ui/AdminNavBar.vue'
</script>

View File

@@ -99,8 +99,87 @@ interface APIToken {
revoked: boolean
}
// 配置相关类型定义
interface SiteConfig {
name: string
description: string
}
interface UploadConfig {
max_file_size_mb: number
max_batch_files: number
max_retention_days: number
require_token: boolean
}
interface StorageConfig {
type: string
local?: {
path: string
}
s3?: {
endpoint: string
region: string
access_key: string
secret_key: string
bucket: string
use_ssl: boolean
}
webdav?: {
url: string
username: string
password: string
root: string
}
}
interface SecurityConfig {
pickup_code_length: number
pickup_fail_limit: number
admin_password_hash: string
jwt_secret: string
}
interface DatabaseConfig {
path: string
}
interface APITokenConfig {
enabled: boolean
max_tokens: number
allow_admin_api: boolean
}
interface SystemConfig {
site: SiteConfig
upload: UploadConfig
storage: StorageConfig
security: SecurityConfig
database: DatabaseConfig
api_token: APITokenConfig
}
interface PublicConfig {
site: SiteConfig
upload: UploadConfig
security: {
pickup_code_length: number
}
storage: {
type: string
}
api_token: {
enabled: boolean
}
}
// 公共 API
export const publicApi = {
// 获取公共配置
getPublicConfig: (): Promise<AxiosResponse<ApiResponse<PublicConfig>>> => {
return api.get('/api/config')
},
// 上传文件
uploadFiles: (formData: FormData, config?: any): Promise<AxiosResponse<ApiResponse<UploadResponse>>> => {
return api.post('/api/batches', formData, {
@@ -203,6 +282,16 @@ export const adminApi = {
deleteToken: (tokenId: number): Promise<AxiosResponse<ApiResponse<void>>> => {
return api.delete(`/admin/api-tokens/${tokenId}`)
},
// 获取系统配置
getConfig: (): Promise<AxiosResponse<SystemConfig>> => {
return api.get('/admin/config')
},
// 更新系统配置
updateConfig: (config: SystemConfig): Promise<AxiosResponse<ApiResponse<void>>> => {
return api.put('/admin/config', config)
},
}
// 工具函数
@@ -303,4 +392,12 @@ export type {
PickupResponse,
BatchListResponse,
APIToken,
SystemConfig,
PublicConfig,
SiteConfig,
UploadConfig,
StorageConfig,
SecurityConfig,
DatabaseConfig,
APITokenConfig,
}

View File

@@ -2,5 +2,14 @@ import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
import { initPublicConfig } from '@/composables/usePublicConfig'
createApp(App).use(router).mount('#app')
async function initApp() {
// 初始化公共配置
await initPublicConfig()
// 创建应用
createApp(App).use(router).mount('#app')
}
initApp()

View File

@@ -10,6 +10,7 @@ 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')
const ConfigManagement = () => import('@/views/admin/ConfigManagement.vue')
// 检查管理员权限
function requireAuth(_to: any, _from: any, next: any) {
@@ -66,6 +67,12 @@ const routes = [
name: 'TokenManagement',
component: TokenManagement,
beforeEnter: requireAuth
},
{
path: '/admin/config',
name: 'ConfigManagement',
component: ConfigManagement,
beforeEnter: requireAuth
}
]

View File

@@ -1,5 +1,6 @@
<template>
<div class="min-h-screen bg-gray-50">
<NavBar :showDescription="true" />
<div class="container mx-auto px-4 py-8">
<!-- 主要内容区域 -->
<div class="max-w-2xl mx-auto">
@@ -224,7 +225,7 @@ 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'
import NavBar from '@/components/ui/NavBar.vue'
// API 和工具导入
import { publicApi, utils, type PickupResponse, type FileItem } from '@/lib/api'

View File

@@ -1,20 +1,7 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50">
<NavBar />
<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">
@@ -229,6 +216,7 @@ 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'
import NavBar from '@/components/ui/NavBar.vue'
// API 和工具导入
import { publicApi, utils, type PickupResponse, type FileItem } from '@/lib/api'

View File

@@ -1,13 +1,8 @@
<template>
<div class="min-h-screen bg-gray-50">
<NavBar />
<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组件 -->
@@ -83,7 +78,7 @@
{{ isDragging ? '释放文件到此处' : '点击选择文件或拖拽到此处' }}
</p>
<p class="text-sm text-gray-400">
支持任意格式文件单文件最大 100MB
支持任意格式文件单文件最大 {{ publicConfig.getFileSizeLimit() }}最多 {{ publicConfig.config.upload?.max_batch_files || 10 }} 个文件
</p>
</div>
</div>
@@ -196,11 +191,9 @@
<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>
<SelectItem v-for="option in publicConfig.getExpireOptions().filter(opt => opt.value > 0)" :key="option.value" :value="String(option.value)">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -215,10 +208,9 @@
<SelectValue placeholder="选择次数限制" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1次后删除</SelectItem>
<SelectItem value="3">3次后删除</SelectItem>
<SelectItem value="5">5次后删除</SelectItem>
<SelectItem value="10">10次后删除</SelectItem>
<SelectItem v-for="option in publicConfig.getDownloadOptions().filter(opt => opt.value > 0)" :key="option.value" :value="String(option.value)">
{{ option.label }}后删除
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -348,11 +340,14 @@ 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'
import NavBar from '@/components/ui/NavBar.vue'
// API 和工具导入
import { publicApi, utils, type UploadResponse } from '@/lib/api'
import { usePublicConfig } from '@/composables/usePublicConfig'
const router = useRouter()
const publicConfig = usePublicConfig()
// 响应式数据
const activeTab = ref('file')
@@ -416,9 +411,14 @@ const addFiles = (files: File[]) => {
}
// 检查文件大小
const oversizedFiles = newFiles.filter(file => file.size > 100 * 1024 * 1024)
const oversizedFiles = newFiles.filter(file => !publicConfig.validateFileSize(file))
if (oversizedFiles.length > 0) {
toast.warning(`${oversizedFiles.length} 个文件超过 100MB 限制`)
toast.warning(`${oversizedFiles.length} 个文件超过 ${publicConfig.getFileSizeLimit()} 限制`)
}
// 检查文件数量
if (!publicConfig.validateFileCount(selectedFiles.value)) {
toast.warning(`最多只能上传 ${publicConfig.config.upload?.max_batch_files || 10} 个文件`)
}
}

View File

@@ -1,59 +1,5 @@
<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">
<AdminLayout>
<!-- 页面标题 -->
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900">管理概览</h2>
@@ -212,7 +158,7 @@
<CardDescription>常用的管理功能</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid grid-cols-1 md:grid-cols-4 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>
@@ -233,6 +179,17 @@
</div>
</Button>
<Button @click="router.push('/admin/config')" variant="outline" class="h-auto p-4 flex-col items-start">
<svg class="w-8 h-8 text-orange-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<div class="text-left">
<div class="font-medium">系统配置</div>
<div class="text-sm text-gray-500">管理系统设置和参数</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>
@@ -245,12 +202,10 @@
</div>
</CardContent>
</Card>
</div>
</div>
<!-- Toast 组件 -->
<Toaster />
</div>
</AdminLayout>
</template>
<script setup lang="ts">
@@ -265,6 +220,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Skeleton } from '@/components/ui/skeleton'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Toaster } from '@/components/ui/sonner'
import AdminLayout from '@/layouts/AdminLayout.vue'
// API 和工具导入
import { adminApi, utils, type FileBatch } from '@/lib/api'
@@ -313,12 +269,6 @@ const refreshData = async () => {
toast.success('数据已刷新')
}
const handleLogout = () => {
localStorage.removeItem('admin_token')
toast.success('已退出登录')
router.push('/admin/login')
}
const getStatusVariant = (status: string) => {
switch (status) {
case 'active':

View File

@@ -1,56 +1,5 @@
<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">
<AdminLayout>
<!-- 页面标题和搜索 -->
<div class="mb-8">
<div class="md:flex md:items-center md:justify-between">
@@ -237,8 +186,6 @@
</div>
</CardContent>
</Card>
</div>
</div>
<!-- 批次详情对话框 -->
<Dialog :open="showDetailDialog" @update:open="showDetailDialog = $event">
@@ -308,12 +255,11 @@
<!-- Toast 组件 -->
<Toaster />
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
// 组件导入
@@ -327,12 +273,11 @@ 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'
import AdminLayout from '@/layouts/AdminLayout.vue'
// API 和工具导入
import { adminApi, utils, type FileBatch } from '@/lib/api'
const router = useRouter()
// 响应式数据
const loading = ref(true)
const batches = ref<FileBatch[]>([])
@@ -450,12 +395,6 @@ const deleteBatch = async (batch: FileBatch) => {
}
}
const handleLogout = () => {
localStorage.removeItem('admin_token')
toast.success('已退出登录')
router.push('/admin/login')
}
const getStatusVariant = (status: string) => {
switch (status) {
case 'active':

View File

@@ -0,0 +1,566 @@
<template>
<AdminLayout>
<div class="space-y-8">
<!-- 页面标题和操作按钮 -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">系统配置管理</h1>
<p class="text-muted-foreground mt-2">管理系统的全局配置参数</p>
</div>
<div class="flex space-x-3">
<Button variant="outline" @click="resetForm" :disabled="loading" class="min-w-20">
<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" />
</svg>
重置
</Button>
<Button @click="saveConfig" :disabled="loading" class="min-w-24">
<span v-if="loading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" 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 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
保存中...
</span>
<span v-else class="flex items-center">
<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="M5 13l4 4L19 7" />
</svg>
保存配置
</span>
</Button>
</div>
</div>
<!-- 配置面板 -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- 站点配置 -->
<Card class="shadow-sm border-l-4 border-l-blue-500">
<CardHeader>
<CardTitle class="flex items-center">
<svg class="w-5 h-5 mr-2 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
</svg>
站点配置
</CardTitle>
<CardDescription>配置站点基本信息和显示内容</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div>
<Label for="siteName" class="text-sm font-medium">站点名称</Label>
<Input
id="siteName"
v-model="config.site.name"
placeholder="输入站点名称,例如:文件暂存柜"
class="mt-2"
/>
</div>
<div>
<Label for="siteDescription" class="text-sm font-medium">站点描述</Label>
<Textarea
id="siteDescription"
v-model="config.site.description"
placeholder="输入站点描述,例如:临时文件中转服务"
class="mt-2 resize-none"
rows="3"
/>
</div>
</CardContent>
</Card>
<!-- 上传配置 -->
<Card class="shadow-sm border-l-4 border-l-green-500">
<CardHeader>
<CardTitle class="flex items-center">
<svg class="w-5 h-5 mr-2 text-green-500" 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" />
</svg>
上传配置
</CardTitle>
<CardDescription>配置文件上传的限制和规则</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div>
<Label for="maxFileSizeMB" class="text-sm font-medium">单文件大小限制 (MB)</Label>
<Input
id="maxFileSizeMB"
v-model.number="config.upload.max_file_size_mb"
type="number"
min="1"
placeholder="100"
class="mt-2"
/>
<p class="text-xs text-muted-foreground mt-1">允许上传的单个文件最大大小</p>
</div>
<div>
<Label for="maxBatchFiles" class="text-sm font-medium">批次最大文件数</Label>
<Input
id="maxBatchFiles"
v-model.number="config.upload.max_batch_files"
type="number"
min="1"
placeholder="20"
class="mt-2"
/>
<p class="text-xs text-muted-foreground mt-1">单次批量上传允许的最大文件数量</p>
</div>
<div>
<Label for="maxRetentionDays" class="text-sm font-medium">最大保存天数</Label>
<Input
id="maxRetentionDays"
v-model.number="config.upload.max_retention_days"
type="number"
min="1"
placeholder="30"
class="mt-2"
/>
<p class="text-xs text-muted-foreground mt-1">文件在系统中的最大保存时间</p>
</div>
<div class="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<Label for="requireToken" class="text-base font-medium">强制要求 Token</Label>
<p class="text-sm text-muted-foreground mt-1">上传时必须提供 API Token</p>
</div>
<Switch
id="requireToken"
v-model="config.upload.require_token"
/>
</div>
</CardContent>
</Card>
<!-- 安全配置 -->
<Card class="shadow-sm border-l-4 border-l-red-500">
<CardHeader>
<CardTitle class="flex items-center">
<svg class="w-5 h-5 mr-2 text-red-500" 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" />
</svg>
安全配置
</CardTitle>
<CardDescription>配置系统安全参数和验证规则</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div>
<Label for="pickupCodeLength" class="text-sm font-medium">取件码长度</Label>
<Input
id="pickupCodeLength"
v-model.number="config.security.pickup_code_length"
type="number"
min="4"
max="20"
placeholder="6"
class="mt-2"
/>
<p class="text-xs text-muted-foreground mt-1">生成取件码的字符长度4-20</p>
</div>
<div>
<Label for="pickupFailLimit" class="text-sm font-medium">取件失败限制次数</Label>
<Input
id="pickupFailLimit"
v-model.number="config.security.pickup_fail_limit"
type="number"
min="1"
placeholder="5"
class="mt-2"
/>
<p class="text-xs text-muted-foreground mt-1">连续取件失败后锁定的次数阈值</p>
</div>
<div>
<Label for="adminPassword" class="text-sm font-medium">管理员密码</Label>
<Input
id="adminPassword"
v-model="adminPassword"
type="password"
placeholder="留空表示不修改密码"
class="mt-2"
/>
<p class="text-xs text-muted-foreground mt-1">
留空表示不修改当前密码输入新密码将更新管理员登录密码
</p>
</div>
</CardContent>
</Card>
<!-- API Token 配置 -->
<Card class="shadow-sm border-l-4 border-l-purple-500">
<CardHeader>
<CardTitle class="flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-500" 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" />
</svg>
API Token 配置
</CardTitle>
<CardDescription>配置 API Token 功能和权限控制</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<Label for="apiTokenEnabled" class="text-base font-medium">启用 API Token</Label>
<p class="text-sm text-muted-foreground mt-1">允许使用 API Token 进行身份验证</p>
</div>
<Switch
id="apiTokenEnabled"
v-model="config.api_token.enabled"
/>
</div>
<div class="space-y-5" :class="{ 'opacity-50': !config.api_token.enabled }">
<div>
<Label for="maxTokens" class="text-sm font-medium">最大 Token 数量</Label>
<Input
id="maxTokens"
v-model.number="config.api_token.max_tokens"
type="number"
min="1"
placeholder="20"
class="mt-2"
:disabled="!config.api_token.enabled"
/>
<p class="text-xs text-muted-foreground mt-1">
系统中最多可以创建的 API Token 数量
</p>
</div>
<div class="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<Label for="allowAdminAPI" class="text-base font-medium">允许管理员权限</Label>
<p class="text-sm text-muted-foreground mt-1">允许 Token 访问管理员接口</p>
</div>
<Switch
id="allowAdminAPI"
v-model="config.api_token.allow_admin_api"
:disabled="!config.api_token.enabled"
/>
</div>
</div>
</CardContent>
</Card>
<!-- 存储配置 -->
<Card class="xl:col-span-2 shadow-sm border-l-4 border-l-orange-500">
<CardHeader>
<CardTitle class="flex items-center">
<svg class="w-5 h-5 mr-2 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
</svg>
存储配置
</CardTitle>
<CardDescription>配置文件存储方式和参数</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div>
<Label class="text-sm font-medium">存储类型</Label>
<Select v-model="config.storage.type" class="mt-2">
<SelectTrigger>
<SelectValue placeholder="选择存储类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">本地存储</SelectItem>
<SelectItem value="s3">S3 兼容存储</SelectItem>
<SelectItem value="webdav">WebDAV</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 本地存储配置 -->
<div v-if="config.storage.type === 'local'" class="space-y-4 border rounded-lg p-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20">
<h4 class="font-semibold text-lg flex items-center">
<svg class="w-5 h-5 mr-2 text-blue-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" />
</svg>
本地存储配置
</h4>
<div>
<Label for="localPath" class="text-sm font-medium">存储路径</Label>
<Input
id="localPath"
v-model="config.storage.local!.path"
placeholder="storage_data"
class="mt-2"
/>
<p class="text-xs text-muted-foreground mt-1">相对或绝对路径用于保存上传的文件</p>
</div>
</div>
<!-- S3 存储配置 -->
<div v-if="config.storage.type === 's3'" class="space-y-6 border rounded-lg p-6 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20">
<h4 class="font-semibold text-lg 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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 12l2 2 4-4" />
</svg>
S3 存储配置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="s3Endpoint" class="text-sm font-medium">Endpoint</Label>
<Input
id="s3Endpoint"
v-model="config.storage.s3!.endpoint"
placeholder="s3.amazonaws.com"
class="mt-2"
/>
</div>
<div>
<Label for="s3Region" class="text-sm font-medium">Region</Label>
<Input
id="s3Region"
v-model="config.storage.s3!.region"
placeholder="us-east-1"
class="mt-2"
/>
</div>
<div>
<Label for="s3Bucket" class="text-sm font-medium">Bucket</Label>
<Input
id="s3Bucket"
v-model="config.storage.s3!.bucket"
placeholder="file-relay-bucket"
class="mt-2"
/>
</div>
<div>
<Label for="s3AccessKey" class="text-sm font-medium">Access Key</Label>
<Input
id="s3AccessKey"
v-model="config.storage.s3!.access_key"
placeholder="your-access-key"
class="mt-2"
/>
</div>
<div class="md:col-span-2">
<Label for="s3SecretKey" class="text-sm font-medium">Secret Key</Label>
<Input
id="s3SecretKey"
v-model="config.storage.s3!.secret_key"
type="password"
placeholder="your-secret-key"
class="mt-2"
/>
</div>
<div class="md:col-span-2">
<div class="flex items-center justify-between p-4 rounded-lg bg-white/80 dark:bg-gray-900/50">
<div>
<Label for="s3UseSSL" class="text-base font-medium">使用 SSL</Label>
<p class="text-sm text-muted-foreground mt-1">启用 HTTPS 连接加密</p>
</div>
<Switch
id="s3UseSSL"
v-model="config.storage.s3!.use_ssl"
/>
</div>
</div>
</div>
</div>
<!-- WebDAV 存储配置 -->
<div v-if="config.storage.type === 'webdav'" class="space-y-6 border rounded-lg p-6 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-950/20 dark:to-violet-950/20">
<h4 class="font-semibold text-lg flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
WebDAV 存储配置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="webdavUrl" class="text-sm font-medium">WebDAV URL</Label>
<Input
id="webdavUrl"
v-model="config.storage.webdav!.url"
placeholder="https://dav.example.com"
class="mt-2"
/>
</div>
<div>
<Label for="webdavRoot" class="text-sm font-medium">根目录</Label>
<Input
id="webdavRoot"
v-model="config.storage.webdav!.root"
placeholder="/file-relay"
class="mt-2"
/>
</div>
<div>
<Label for="webdavUsername" class="text-sm font-medium">用户名</Label>
<Input
id="webdavUsername"
v-model="config.storage.webdav!.username"
placeholder="user"
class="mt-2"
/>
</div>
<div>
<Label for="webdavPassword" class="text-sm font-medium">密码</Label>
<Input
id="webdavPassword"
v-model="config.storage.webdav!.password"
type="password"
placeholder="pass"
class="mt-2"
/>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 数据库配置 -->
<Card class="xl:col-span-2 shadow-sm border-l-4 border-l-indigo-500">
<CardHeader>
<CardTitle class="flex items-center">
<svg class="w-5 h-5 mr-2 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
数据库配置
</CardTitle>
<CardDescription>配置 SQLite 数据库存储路径</CardDescription>
</CardHeader>
<CardContent>
<div>
<Label for="databasePath" class="text-sm font-medium">数据库文件路径</Label>
<Input
id="databasePath"
v-model="config.database.path"
placeholder="file_relay.db"
class="mt-2"
/>
<p class="text-xs text-muted-foreground mt-1">SQLite 数据库文件的存储路径</p>
</div>
</CardContent>
</Card>
</div>
<!-- Toast 组件 -->
<Toaster />
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import { adminApi, type SystemConfig } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Toaster } from '@/components/ui/sonner'
import AdminLayout from '@/layouts/AdminLayout.vue'
// 使用 toast
const showToast = toast
const loading = ref(false)
const adminPassword = ref('')
const originalConfig = ref<SystemConfig>()
const config = reactive<SystemConfig>({
site: {
name: '',
description: ''
},
upload: {
max_file_size_mb: 100,
max_batch_files: 20,
max_retention_days: 30,
require_token: false
},
storage: {
type: 'local',
local: {
path: 'storage_data'
},
s3: {
endpoint: 's3.amazonaws.com',
region: 'us-east-1',
bucket: 'file-relay-bucket',
access_key: 'your-access-key',
secret_key: 'your-secret-key',
use_ssl: false
},
webdav: {
url: 'https://dav.example.com',
username: 'user',
password: 'pass',
root: '/file-relay'
}
},
security: {
pickup_code_length: 6,
pickup_fail_limit: 5,
admin_password_hash: '',
jwt_secret: ''
},
database: {
path: 'file_relay.db'
},
api_token: {
enabled: true,
max_tokens: 20,
allow_admin_api: false
}
})
const loadConfig = async () => {
try {
loading.value = true
const response = await adminApi.getConfig()
const configData = response.data
// 直接赋值,保持 snake_case 格式
Object.assign(config, configData)
// 保存原始配置副本
originalConfig.value = JSON.parse(JSON.stringify(config))
console.log('配置加载成功:', config)
} catch (error) {
console.error('加载配置失败:', error)
showToast.error('加载配置失败,请检查网络连接或重新登录')
} finally {
loading.value = false
}
}
const resetForm = () => {
if (originalConfig.value) {
Object.assign(config, JSON.parse(JSON.stringify(originalConfig.value)))
adminPassword.value = ''
showToast.success('表单已重置,配置已恢复到最后一次保存的状态')
}
}
const saveConfig = async () => {
try {
loading.value = true
// 构建要提交的配置
const configToSave = JSON.parse(JSON.stringify(config))
// 如果设置了新密码,添加到 security 配置中
if (adminPassword.value) {
configToSave.security.admin_password = adminPassword.value
}
await adminApi.updateConfig(configToSave)
// 更新原始配置
originalConfig.value = JSON.parse(JSON.stringify(config))
adminPassword.value = ''
showToast.success('配置保存成功')
} catch (error: any) {
console.error('保存配置失败:', error)
showToast.error(error.response?.data?.msg || '保存配置失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadConfig()
})
</script>

View File

@@ -1,56 +1,5 @@
<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">
<AdminLayout>
<!-- 页面标题和操作 -->
<div class="mb-8">
<div class="md:flex md:items-center md:justify-between">
@@ -207,8 +156,6 @@
</div>
</CardContent>
</Card>
</div>
</div>
<!-- 创建 Token 对话框 -->
<Dialog :open="showCreateDialog" @update:open="showCreateDialog = $event">
@@ -345,12 +292,11 @@
<!-- Toast 组件 -->
<Toaster />
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
// 组件导入
@@ -364,12 +310,11 @@ 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'
import AdminLayout from '@/layouts/AdminLayout.vue'
// API 和工具导入
import { adminApi, utils, type APIToken } from '@/lib/api'
const router = useRouter()
// 响应式数据
const loading = ref(true)
const tokens = ref<APIToken[]>([])
@@ -527,12 +472,6 @@ const refreshData = () => {
toast.success('数据已刷新')
}
const handleLogout = () => {
localStorage.removeItem('admin_token')
toast.success('已退出登录')
router.push('/admin/login')
}
const formatDate = (dateString: string): string => {
return utils.formatDate(dateString)
}