feat: 支持S3预签名上传

This commit is contained in:
Lan
2025-12-31 03:25:09 +08:00
parent e67c39b672
commit a487be2412
25 changed files with 825 additions and 336 deletions
+26
View File
@@ -0,0 +1,26 @@
from tortoise import connections
async def create_presign_upload_session_table():
conn = connections.get("default")
await conn.execute_script(
"""
CREATE TABLE IF NOT EXISTS presignuploadsession (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
upload_id VARCHAR(36) NOT NULL UNIQUE,
file_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
save_path VARCHAR(512) NOT NULL,
mode VARCHAR(10) NOT NULL,
expire_value INT NOT NULL DEFAULT 1,
expire_style VARCHAR(20) NOT NULL DEFAULT 'day',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_presignuploadsession_upload_id ON presignuploadsession (upload_id);
"""
)
async def migrate():
await create_presign_upload_session_table()
+19
View File
@@ -64,6 +64,25 @@ class KeyValue(Model):
) )
class PresignUploadSession(models.Model):
"""预签名上传会话模型"""
id = fields.IntField(pk=True)
upload_id = fields.CharField(max_length=36, unique=True, index=True)
file_name = fields.CharField(max_length=255)
file_size = fields.BigIntField()
save_path = fields.CharField(max_length=512)
mode = fields.CharField(max_length=10) # "direct" 或 "proxy"
expire_value = fields.IntField(default=1)
expire_style = fields.CharField(max_length=20, default="day")
created_at = fields.DatetimeField(auto_now_add=True)
expires_at = fields.DatetimeField() # 会话过期时间
async def is_expired(self):
"""检查会话是否已过期"""
return self.expires_at < await get_now()
file_codes_pydantic = pydantic_model_creator(FileCodes, name="FileCodes") file_codes_pydantic = pydantic_model_creator(FileCodes, name="FileCodes")
upload_chunk_pydantic = pydantic_model_creator(UploadChunk, name="UploadChunk") upload_chunk_pydantic = pydantic_model_creator(UploadChunk, name="UploadChunk")
key_value_pydantic = pydantic_model_creator(KeyValue, name="KeyValue") key_value_pydantic = pydantic_model_creator(KeyValue, name="KeyValue")
presign_upload_session_pydantic = pydantic_model_creator(PresignUploadSession, name="PresignUploadSession")
+19
View File
@@ -1,4 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
class SelectFileModel(BaseModel): class SelectFileModel(BaseModel):
@@ -15,3 +16,21 @@ class InitChunkUploadModel(BaseModel):
class CompleteUploadModel(BaseModel): class CompleteUploadModel(BaseModel):
expire_value: int expire_value: int
expire_style: str expire_style: str
# 预签名上传相关模型
class PresignUploadInitRequest(BaseModel):
"""预签名上传初始化请求"""
file_name: str
file_size: int
expire_value: int = 1
expire_style: str = "day"
class PresignUploadInitResponse(BaseModel):
"""预签名上传初始化响应"""
upload_id: str
upload_url: str
mode: str # "direct" 或 "proxy"
save_path: str
expires_in: int # URL过期时间(秒)
+193 -3
View File
@@ -1,21 +1,70 @@
import datetime
import hashlib import hashlib
import os
import uuid import uuid
from datetime import timedelta
from urllib.parse import unquote
from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException
from starlette import status from starlette import status
from apps.admin.dependencies import share_required_login from apps.admin.dependencies import share_required_login
from apps.base.models import FileCodes, UploadChunk from apps.base.models import FileCodes, UploadChunk, PresignUploadSession
from apps.base.schemas import SelectFileModel, InitChunkUploadModel, CompleteUploadModel from apps.base.schemas import SelectFileModel, InitChunkUploadModel, CompleteUploadModel, PresignUploadInitRequest
from apps.base.utils import get_expire_info, get_file_path_name, ip_limit, get_chunk_file_path_name from apps.base.utils import get_expire_info, get_file_path_name, ip_limit, get_chunk_file_path_name
from core.response import APIResponse from core.response import APIResponse
from core.settings import settings from core.settings import settings
from core.storage import storages, FileStorageInterface from core.storage import storages, FileStorageInterface
from core.utils import get_select_token from core.utils import get_select_token, get_now, sanitize_filename
share_api = APIRouter(prefix="/share", tags=["分享"]) share_api = APIRouter(prefix="/share", tags=["分享"])
# ============ 公共服务层 ============
class FileUploadService:
"""统一的文件上传服务"""
@staticmethod
async def generate_file_path(file_name: str, upload_id: str = None) -> tuple[str, str, str, str, str]:
"""统一的路径生成"""
today = datetime.datetime.now()
storage_path = settings.storage_path.strip("/")
file_uuid = upload_id or uuid.uuid4().hex
filename = await sanitize_filename(unquote(file_name))
base_path = f"share/data/{today.strftime('%Y/%m/%d')}/{file_uuid}"
path = f"{storage_path}/{base_path}" if storage_path else base_path
prefix, suffix = os.path.splitext(filename)
save_path = f"{path}/{filename}"
return path, suffix, prefix, filename, save_path
@staticmethod
async def create_file_record(
file_name: str,
file_size: int,
file_path: str,
expire_value: int,
expire_style: str,
**extra_fields
) -> str:
"""统一创建FileCodes记录,返回code"""
expired_at, expired_count, used_count, code = await get_expire_info(expire_value, expire_style)
prefix, suffix = os.path.splitext(file_name)
await FileCodes.create(
code=code,
prefix=prefix,
suffix=suffix,
uuid_file_name=file_name,
file_path=file_path,
size=file_size,
expired_at=expired_at,
expired_count=expired_count,
used_count=used_count,
**extra_fields
)
return code
async def validate_file_size(file: UploadFile, max_size: int) -> int: async def validate_file_size(file: UploadFile, max_size: int) -> int:
size = file.size size = file.size
if size is None: if size is None:
@@ -425,3 +474,144 @@ async def complete_upload(upload_id: str, data: CompleteUploadModel, ip: str = D
except Exception: except Exception:
pass pass
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"文件合并失败: {str(e)}") raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"文件合并失败: {str(e)}")
# ============ 预签名上传API ============
presign_api = APIRouter(prefix="/presign", tags=["预签名上传"])
PRESIGN_SESSION_EXPIRES = 900 # 15分钟
async def _get_valid_session(upload_id: str, expected_mode: str = None) -> PresignUploadSession:
"""获取并验证会话"""
session = await PresignUploadSession.filter(upload_id=upload_id).first()
if not session:
raise HTTPException(404, "上传会话不存在")
if await session.is_expired():
await session.delete()
raise HTTPException(404, "上传会话已过期")
if expected_mode and session.mode != expected_mode:
raise HTTPException(400, f"此会话不支持{expected_mode}模式")
return session
@presign_api.post("/upload/init", dependencies=[Depends(share_required_login)])
async def presign_upload_init(data: PresignUploadInitRequest, ip: str = Depends(ip_limit["upload"])):
"""初始化预签名上传,S3返回直传URL,其他存储返回代理URL"""
if data.file_size > settings.uploadSize:
raise HTTPException(403, f"文件大小超过限制,最大为 {settings.uploadSize / (1024*1024):.2f} MB")
if data.expire_style not in settings.expireStyle:
raise HTTPException(400, "过期时间类型错误")
upload_id = uuid.uuid4().hex
path, _, _, filename, save_path = await FileUploadService.generate_file_path(data.file_name, upload_id)
storage: FileStorageInterface = storages[settings.file_storage]()
presigned_url = await storage.generate_presigned_upload_url(save_path, PRESIGN_SESSION_EXPIRES)
mode = "direct" if presigned_url else "proxy"
upload_url = presigned_url or f"/api/presign/upload/proxy/{upload_id}"
await PresignUploadSession.create(
upload_id=upload_id,
file_name=filename,
file_size=data.file_size,
save_path=save_path,
mode=mode,
expire_value=data.expire_value,
expire_style=data.expire_style,
expires_at=await get_now() + timedelta(seconds=PRESIGN_SESSION_EXPIRES),
)
ip_limit["upload"].add_ip(ip)
return APIResponse(detail={
"upload_id": upload_id,
"upload_url": upload_url,
"mode": mode,
"expires_in": PRESIGN_SESSION_EXPIRES,
})
@presign_api.put("/upload/proxy/{upload_id}", dependencies=[Depends(share_required_login)])
async def presign_upload_proxy(upload_id: str, file: UploadFile = File(...), ip: str = Depends(ip_limit["upload"])):
"""代理模式上传,服务器转存到存储后端"""
session = await _get_valid_session(upload_id, expected_mode="proxy")
file_size = await validate_file_size(file, settings.uploadSize)
if abs(file_size - session.file_size) > 1024:
raise HTTPException(400, "文件大小与声明不符")
storage: FileStorageInterface = storages[settings.file_storage]()
try:
await storage.save_file(file, session.save_path)
except Exception as e:
raise HTTPException(500, f"文件保存失败: {str(e)}")
code = await FileUploadService.create_file_record(
session.file_name, file_size, os.path.dirname(session.save_path),
session.expire_value, session.expire_style
)
await session.delete()
ip_limit["upload"].add_ip(ip)
return APIResponse(detail={"code": code, "name": session.file_name})
@presign_api.post("/upload/confirm/{upload_id}", dependencies=[Depends(share_required_login)])
async def presign_upload_confirm(upload_id: str, ip: str = Depends(ip_limit["upload"])):
"""直传确认,客户端完成S3直传后调用获取分享码"""
session = await _get_valid_session(upload_id, expected_mode="direct")
storage: FileStorageInterface = storages[settings.file_storage]()
if not await storage.file_exists(session.save_path):
raise HTTPException(404, "文件未上传或上传失败")
code = await FileUploadService.create_file_record(
session.file_name, session.file_size, os.path.dirname(session.save_path),
session.expire_value, session.expire_style
)
await session.delete()
ip_limit["upload"].add_ip(ip)
return APIResponse(detail={"code": code, "name": session.file_name})
@presign_api.get("/upload/status/{upload_id}", dependencies=[Depends(share_required_login)])
async def presign_upload_status(upload_id: str):
"""查询上传会话状态"""
session = await PresignUploadSession.filter(upload_id=upload_id).first()
if not session:
raise HTTPException(404, "上传会话不存在")
return APIResponse(detail={
"upload_id": session.upload_id,
"file_name": session.file_name,
"file_size": session.file_size,
"mode": session.mode,
"created_at": session.created_at.isoformat(),
"expires_at": session.expires_at.isoformat(),
"is_expired": await session.is_expired(),
})
@presign_api.delete("/upload/{upload_id}", dependencies=[Depends(share_required_login)])
async def presign_upload_cancel(upload_id: str):
"""取消上传会话"""
session = await PresignUploadSession.filter(upload_id=upload_id).first()
if not session:
raise HTTPException(404, "上传会话不存在")
if session.mode == "direct":
storage: FileStorageInterface = storages[settings.file_storage]()
try:
if await storage.file_exists(session.save_path):
temp_file_code = FileCodes(
file_path=os.path.dirname(session.save_path),
uuid_file_name=os.path.basename(session.save_path),
)
await storage.delete_file(temp_file_code)
except Exception:
pass
await session.delete()
return APIResponse(detail={"message": "上传会话已取消"})
+110
View File
@@ -79,6 +79,23 @@ class FileStorageInterface:
""" """
raise NotImplementedError raise NotImplementedError
async def generate_presigned_upload_url(self, save_path: str, expires_in: int = 900) -> Optional[str]:
"""
生成预签名上传URL
:param save_path: 文件保存路径
:param expires_in: URL过期时间(秒),默认15分钟
:return: 预签名URL,如果不支持直传则返回None
"""
return None # 默认不支持直传,使用代理模式
async def file_exists(self, save_path: str) -> bool:
"""
检查文件是否存在
:param save_path: 文件路径
:return: 文件是否存在
"""
raise NotImplementedError
async def clean_chunks(self, upload_id: str, save_path: str): async def clean_chunks(self, upload_id: str, save_path: str):
""" """
清理临时分片文件 清理临时分片文件
@@ -219,6 +236,15 @@ class SystemFileStorage(FileStorageInterface):
except Exception as e: except Exception as e:
logger.info(f"清理 chunks 父目录失败: {e}") logger.info(f"清理 chunks 父目录失败: {e}")
async def file_exists(self, save_path: str) -> bool:
"""
检查文件是否存在于本地文件系统
:param save_path: 文件路径
:return: 文件是否存在
"""
file_path = self.root_path / save_path
return file_path.exists()
class S3FileStorage(FileStorageInterface): class S3FileStorage(FileStorageInterface):
def __init__(self): def __init__(self):
@@ -425,6 +451,48 @@ class S3FileStorage(FileStorageInterface):
except Exception as e: except Exception as e:
logger.info(f"清理 S3 分片数据时出错: {e}") logger.info(f"清理 S3 分片数据时出错: {e}")
async def generate_presigned_upload_url(self, save_path: str, expires_in: int = 900) -> Optional[str]:
"""
生成S3预签名上传URL
:param save_path: 文件保存路径
:param expires_in: URL过期时间(秒),默认15分钟
:return: 预签名PUT URL
"""
async with self.session.client(
"s3",
endpoint_url=self.endpoint_url,
aws_session_token=self.aws_session_token,
region_name=self.region_name,
config=Config(signature_version=self.signature_version),
) as s3:
return await s3.generate_presigned_url(
"put_object",
Params={
"Bucket": self.bucket_name,
"Key": save_path,
},
ExpiresIn=expires_in,
)
async def file_exists(self, save_path: str) -> bool:
"""
检查文件是否存在于S3
:param save_path: 文件路径
:return: 文件是否存在
"""
async with self.session.client(
"s3",
endpoint_url=self.endpoint_url,
aws_session_token=self.aws_session_token,
region_name=self.region_name,
config=Config(signature_version=self.signature_version),
) as s3:
try:
await s3.head_object(Bucket=self.bucket_name, Key=save_path)
return True
except Exception:
return False
class OneDriveFileStorage(FileStorageInterface): class OneDriveFileStorage(FileStorageInterface):
def __init__(self): def __init__(self):
@@ -660,6 +728,25 @@ class OneDriveFileStorage(FileStorageInterface):
except Exception as e: except Exception as e:
logger.info(f"清理 OneDrive 分片时出错: {e}") logger.info(f"清理 OneDrive 分片时出错: {e}")
def _file_exists(self, save_path: str) -> bool:
"""同步检查文件是否存在"""
try:
path = self._get_path_str(save_path)
self.root_path.get_by_path(path).get().execute_query()
return True
except self._ClientRequestException as e:
if e.code == "itemNotFound":
return False
raise e
async def file_exists(self, save_path: str) -> bool:
"""
检查文件是否存在于OneDrive
:param save_path: 文件路径
:return: 文件是否存在
"""
return await asyncio.to_thread(self._file_exists, save_path)
class OpenDALFileStorage(FileStorageInterface): class OpenDALFileStorage(FileStorageInterface):
def __init__(self): def __init__(self):
@@ -743,6 +830,18 @@ class OpenDALFileStorage(FileStorageInterface):
except Exception as e: except Exception as e:
logger.info(f"清理 OpenDAL 分片时出错: {e}") logger.info(f"清理 OpenDAL 分片时出错: {e}")
async def file_exists(self, save_path: str) -> bool:
"""
检查文件是否存在于OpenDAL存储
:param save_path: 文件路径
:return: 文件是否存在
"""
try:
await self.operator.stat(save_path)
return True
except Exception:
return False
class WebDAVFileStorage(FileStorageInterface): class WebDAVFileStorage(FileStorageInterface):
_instance: Optional["WebDAVFileStorage"] = None _instance: Optional["WebDAVFileStorage"] = None
@@ -997,6 +1096,17 @@ class WebDAVFileStorage(FileStorageInterface):
except Exception as e: except Exception as e:
logger.info(f"清理 WebDAV 分片时出错: {e}") logger.info(f"清理 WebDAV 分片时出错: {e}")
async def file_exists(self, save_path: str) -> bool:
"""
检查文件是否存在于WebDAV
:param save_path: 文件路径
:return: 文件是否存在
"""
url = self._build_url(save_path)
async with aiohttp.ClientSession(auth=self.auth) as session:
async with session.head(url) as resp:
return resp.status == 200
storages = { storages = {
"local": SystemFileStorage, "local": SystemFileStorage,
+454
View File
@@ -0,0 +1,454 @@
# 预签名上传 API 文档
## 概述
预签名上传功能提供统一的文件上传接口,根据后端存储类型自动选择最优上传方式:
- **S3 存储**: 返回预签名 URL,客户端直传 S3(减少服务器带宽压力)
- **其他存储**: 返回代理上传 URL,通过服务器中转上传
## 上传流程
### 流程图
```
┌─────────┐ 1. 初始化上传 ┌─────────┐
│ 客户端 │ ──────────────────────▶ │ 服务器 │
└─────────┘ └─────────┘
│ │
│◀─────── 返回 upload_url + mode ──┤
│ │
│ ┌─────────────────────────────────────────┐
│ │ if mode == "direct" (S3存储) │
│ │ 2a. PUT 文件到 upload_url (S3) │
│ │ 3a. POST /confirm 确认上传 │
│ │ │
│ │ if mode == "proxy" (其他存储) │
│ │ 2b. PUT 文件到 upload_url (服务器) │
│ │ (自动返回分享码,无需确认) │
│ └─────────────────────────────────────────┘
获取分享码
```
---
## API 端点
### 1. 初始化上传
初始化预签名上传会话,获取上传 URL 和模式。
**请求**
```
POST /presign/upload/init
Content-Type: application/json
```
**请求体**
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| ------------ | ------- | ---- | ------ | --------------------------------------- |
| file_name | string | ✅ | - | 文件名(含扩展名) |
| file_size | integer | ✅ | - | 文件大小(字节) |
| expire_value | integer | ❌ | 1 | 过期时间值 |
| expire_style | string | ❌ | "day" | 过期类型:day/hour/minute/forever/count |
**请求示例**
```json
{
"file_name": "document.pdf",
"file_size": 1048576,
"expire_value": 7,
"expire_style": "day"
}
```
**响应**
```json
{
"code": 200,
"detail": {
"upload_id": "a1b2c3d4e5f6...",
"upload_url": "https://bucket.s3.amazonaws.com/path?X-Amz-Signature=...",
"mode": "direct",
"save_path": "share/data/2024/01/01/uuid/document.pdf",
"expires_in": 900
}
}
```
**响应字段说明**
| 字段 | 类型 | 说明 |
| ---------- | ------- | ----------------------------------------------------- |
| upload_id | string | 上传会话 ID,后续操作需要 |
| upload_url | string | 上传目标 URL |
| mode | string | 上传模式:`direct`(直传 S3)或 `proxy`(服务器代理) |
| save_path | string | 文件存储路径 |
| expires_in | integer | URL 有效期(秒),默认 900 秒(15 分钟) |
**错误响应**
| 状态码 | 说明 |
| ------ | ------------------------------ |
| 400 | 过期时间类型错误 |
| 403 | 文件大小超过限制 / IP 频率限制 |
---
### 2a. 直传模式 - 上传文件到 S3
`mode == "direct"` 时,客户端直接将文件 PUT 到返回的预签名 URL。
**请求**
```
PUT {upload_url}
Content-Type: application/octet-stream
[文件二进制内容]
```
**注意事项**
- 直接使用返回的 `upload_url`,不要修改
- Content-Type 建议使用 `application/octet-stream`
- 这是直接请求 S3,不经过服务器
**JavaScript 示例**
```javascript
const response = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': 'application/octet-stream',
},
})
if (response.ok) {
// 上传成功,调用确认接口
}
```
---
### 2b. 代理模式 - 上传文件到服务器
`mode == "proxy"` 时,客户端将文件 PUT 到服务器代理端点。
**请求**
```
PUT /presign/upload/proxy/{upload_id}
Content-Type: multipart/form-data
file: [文件]
```
**路径参数**
| 参数 | 说明 |
| --------- | ------------------------- |
| upload_id | 初始化时返回的上传会话 ID |
**响应**
```json
{
"code": 200,
"detail": {
"code": "123456",
"name": "document.pdf"
}
}
```
**注意**: 代理模式上传成功后直接返回分享码,无需调用确认接口。
**错误响应**
| 状态码 | 说明 |
| ------ | --------------------------------------- |
| 400 | 文件大小与声明不符 / 会话不支持代理上传 |
| 404 | 上传会话不存在或已过期 |
| 500 | 文件保存失败 |
---
### 3. 确认上传(仅直传模式)
直传模式下,客户端完成 S3 上传后调用此接口确认并获取分享码。
**请求**
```
POST /presign/upload/confirm/{upload_id}
Content-Type: application/json
```
**路径参数**
| 参数 | 说明 |
| --------- | ------------------------- |
| upload_id | 初始化时返回的上传会话 ID |
**请求体**
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| ------------ | ------- | ---- | ------ | ---------- |
| expire_value | integer | ❌ | 1 | 过期时间值 |
| expire_style | string | ❌ | "day" | 过期类型 |
**请求示例**
```json
{
"expire_value": 7,
"expire_style": "day"
}
```
**响应**
```json
{
"code": 200,
"detail": {
"code": "123456",
"name": "document.pdf"
}
}
```
**错误响应**
| 状态码 | 说明 |
| ------ | --------------------------------------------- |
| 400 | 会话不支持直传确认 |
| 404 | 上传会话不存在或已过期 / 文件未上传或上传失败 |
---
### 4. 查询上传状态
查询上传会话的当前状态。
**请求**
```
GET /presign/upload/status/{upload_id}
```
**响应**
```json
{
"code": 200,
"detail": {
"upload_id": "a1b2c3d4e5f6...",
"file_name": "document.pdf",
"file_size": 1048576,
"mode": "direct",
"created_at": "2024-01-01T12:00:00",
"expires_at": "2024-01-01T12:15:00",
"is_expired": false
}
}
```
**错误响应**
| 状态码 | 说明 |
| ------ | -------------- |
| 404 | 上传会话不存在 |
---
### 5. 取消上传
取消上传会话并清理相关资源。
**请求**
```
DELETE /presign/upload/{upload_id}
```
**响应**
```json
{
"code": 200,
"detail": {
"message": "上传会话已取消"
}
}
```
**错误响应**
| 状态码 | 说明 |
| ------ | -------------- |
| 404 | 上传会话不存在 |
---
## 前端集成示例
### 完整上传流程(JavaScript/TypeScript
```typescript
interface PresignInitResponse {
upload_id: string
upload_url: string
mode: 'direct' | 'proxy'
save_path: string
expires_in: number
}
interface UploadResult {
code: string
name: string
}
async function uploadFile(
file: File,
expireValue: number = 1,
expireStyle: string = 'day'
): Promise<UploadResult> {
// 1. 初始化上传
const initResponse = await fetch('/presign/upload/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_name: file.name,
file_size: file.size,
expire_value: expireValue,
expire_style: expireStyle,
}),
})
const initData = await initResponse.json()
if (initData.code !== 200) {
throw new Error(initData.detail)
}
const { upload_id, upload_url, mode } = initData.detail as PresignInitResponse
// 2. 根据模式上传文件
if (mode === 'direct') {
// 直传模式:上传到S3
const uploadResponse = await fetch(upload_url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': 'application/octet-stream' },
})
if (!uploadResponse.ok) {
throw new Error('S3上传失败')
}
// 3. 确认上传
const confirmResponse = await fetch(
`/presign/upload/confirm/${upload_id}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expire_value: expireValue,
expire_style: expireStyle,
}),
}
)
const confirmData = await confirmResponse.json()
if (confirmData.code !== 200) {
throw new Error(confirmData.detail)
}
return confirmData.detail
} else {
// 代理模式:上传到服务器
const formData = new FormData()
formData.append('file', file)
const uploadResponse = await fetch(upload_url, {
method: 'PUT',
body: formData,
})
const uploadData = await uploadResponse.json()
if (uploadData.code !== 200) {
throw new Error(uploadData.detail)
}
return uploadData.detail
}
}
// 使用示例
const file = document.querySelector('input[type="file"]').files[0]
const result = await uploadFile(file, 7, 'day')
console.log('分享码:', result.code)
```
### Vue 3 组件示例
```vue
<template>
<div>
<input type="file" @change="handleFileSelect" />
<button @click="upload" :disabled="!selectedFile || uploading">
{{ uploading ? '上传中...' : '上传' }}
</button>
<div v-if="shareCode">分享码: {{ shareCode }}</div>
<div v-if="error" class="error">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selectedFile = ref<File | null>(null)
const uploading = ref(false)
const shareCode = ref('')
const error = ref('')
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
selectedFile.value = input.files?.[0] || null
}
async function upload() {
if (!selectedFile.value) return
uploading.value = true
error.value = ''
try {
const result = await uploadFile(selectedFile.value)
shareCode.value = result.code
} catch (e) {
error.value = e.message
} finally {
uploading.value = false
}
}
</script>
```
---
## 注意事项
1. **会话有效期**: 上传会话默认 15 分钟后过期,请在有效期内完成上传
2. **文件大小限制**: 受系统配置 `uploadSize` 限制
3. **过期类型**: 支持 `day``hour``minute``forever``count`
4. **CORS**: 直传模式下,S3 需要配置正确的 CORS 策略
5. **重试机制**: 建议实现上传失败重试逻辑
+2 -1
View File
@@ -16,7 +16,7 @@ from tortoise.contrib.fastapi import register_tortoise
from apps.admin.views import admin_api from apps.admin.views import admin_api
from apps.base.models import KeyValue from apps.base.models import KeyValue
from apps.base.utils import ip_limit from apps.base.utils import ip_limit
from apps.base.views import share_api, chunk_api from apps.base.views import share_api, chunk_api, presign_api
from core.database import init_db from core.database import init_db
from core.logger import logger from core.logger import logger
from core.response import APIResponse from core.response import APIResponse
@@ -98,6 +98,7 @@ register_tortoise(
app.include_router(share_api) app.include_router(share_api)
app.include_router(chunk_api) app.include_router(chunk_api)
app.include_router(presign_api)
app.include_router(admin_api) app.include_router(admin_api)
@@ -1,21 +0,0 @@
import{c as l,d as _,u as M,r as p,f as m,L as C,a as b,o as h,b as t,n as o,e,i as z,k as i,B as L,t as x,X as B,F as D,x as j,z as F,D as N,y as S,M as V}from"./index-14Tp7zPK.js";import{B as E}from"./box-CiSQlKtb.js";/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const I=l("cog",[["path",{d:"M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z",key:"sobvz5"}],["path",{d:"M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z",key:"11i496"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 22v-2",key:"1osdcq"}],["path",{d:"m17 20.66-1-1.73",key:"eq3orb"}],["path",{d:"M11 10.27 7 3.34",key:"16pf9h"}],["path",{d:"m20.66 17-1.73-1",key:"sg0v6f"}],["path",{d:"m3.34 7 1.73 1",key:"1ulond"}],["path",{d:"M14 12h8",key:"4f43i9"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"m20.66 7-1.73 1",key:"1ow05n"}],["path",{d:"m3.34 17 1.73-1",key:"nuk764"}],["path",{d:"m17 3.34-1 1.73",key:"2wel8s"}],["path",{d:"m11 13.73-4 6.93",key:"794ttg"}]]);/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const R=l("folder",[["path",{d:"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",key:"1kt360"}]]);/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Z=l("layout-dashboard",[["rect",{width:"7",height:"9",x:"3",y:"3",rx:"1",key:"10lvy0"}],["rect",{width:"7",height:"5",x:"14",y:"3",rx:"1",key:"16une8"}],["rect",{width:"7",height:"9",x:"14",y:"12",rx:"1",key:"1hutg5"}],["rect",{width:"7",height:"5",x:"3",y:"16",rx:"1",key:"ldoo1y"}]]);/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const q=l("menu",[["path",{d:"M4 12h16",key:"1lakjw"}],["path",{d:"M4 18h16",key:"19g7jn"}],["path",{d:"M4 6h16",key:"1o0s65"}]]),A={class:"flex items-center"},$={class:"rounded-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 p-1 animate-spin-slow"},H={class:"flex-1 overflow-y-auto custom-scrollbar"},O={class:"p-4 space-y-2"},T=["onClick"],U={class:"flex-1 flex flex-col h-full"},W={class:"flex items-center justify-between h-16 px-4"},P=_({__name:"AdminLayout",setup(X){const d=L(),{t:n}=M(),a=z("isDarkMode"),f=[{id:"Dashboard",name:n("admin.dashboard.title"),icon:Z,redirect:"/admin/dashboard"},{id:"FileManage",name:n("admin.fileManage.title"),icon:R,redirect:"/admin/files"},{id:"Settings",name:n("admin.settings.title"),icon:I,redirect:"/admin/settings"}],r=p(!0),g=()=>{r.value=!r.value},c=()=>{window.innerWidth>=1024?r.value=!0:r.value=!1};m(()=>{c(),window.addEventListener("resize",c)}),C(()=>{window.removeEventListener("resize",c)});const k=p({page:1,size:10,total:0}),v=async()=>{try{k.value.total=85}catch(u){console.error("加载文件列表失败:",u)}};return m(()=>{v()}),(u,y)=>{const w=F("router-view");return h(),b("div",{class:o(["h-screen flex flex-col lg:flex-row transition-colors duration-300",[e(a)?"bg-gray-900":"bg-gray-50"]])},[t("aside",{class:o(["fixed inset-y-0 border-transparent left-0 z-50 w-64 transform transition-all duration-300 ease-in-out lg:relative lg:translate-x-0 border-r flex flex-col h-full lg:h-screen",[e(a)?"bg-gray-800 bg-opacity-90 backdrop-filter backdrop-blur-xl ":"bg-white ",{"-translate-x-full":!r.value}]])},[t("div",{class:o(["flex items-center justify-between h-16 px-4 border-b",[e(a)?"border-gray-700":"border-gray-200"]])},[t("div",A,[t("div",$,[t("div",{class:o(["rounded-full p-1",[e(a)?"bg-gray-800":"bg-white"]])},[i(e(E),{class:o(["w-6 h-6",[e(a)?"text-indigo-400":"text-indigo-600"]])},null,8,["class"])],2)]),t("h1",{onClick:y[0]||(y[0]=s=>e(d).push("/")),class:o(["ml-2 text-xl font-semibold cursor-pointer",[e(a)?"text-white":"text-gray-800"]])},x(e(n)("common.appName")),3)]),t("button",{onClick:g,class:"lg:hidden"},[i(e(B),{class:o(["w-6 h-6",[e(a)?"text-gray-400":"text-gray-600"]])},null,8,["class"])])],2),t("nav",H,[t("ul",O,[(h(),b(D,null,j(f,s=>t("li",{key:s.id},[t("a",{onClick:G=>e(d).push(s.redirect),class:o(["flex items-center p-2 rounded-lg transition-colors duration-200",[e(d).currentRoute.value.name===s.id?e(a)?"bg-indigo-900 text-indigo-400":"bg-indigo-100 text-indigo-600":e(a)?"text-gray-400 hover:bg-gray-700":"text-gray-600 hover:bg-gray-100"]])},[(h(),N(V(s.icon),{class:"w-5 h-5 mr-3"})),S(" "+x(s.name),1)],10,T)])),64))])])],2),t("div",U,[t("header",{class:o(["shadow-md border-b transition-colors duration-300 h-16",[e(a)?"bg-gray-800 border-gray-700":"bg-white border-gray-200"]])},[t("div",W,[t("button",{onClick:g,class:"lg:hidden"},[i(e(q),{class:o(["w-6 h-6",[e(a)?"text-gray-400":"text-gray-600"]])},null,8,["class"])])])],2),t("main",{class:o(["overflow-y-auto transition-colors duration-300 custom-scrollbar",[e(a)?"bg-gray-900":"bg-gray-50"]])},[i(w)],2)])],2)}}});export{P as default};
@@ -1 +0,0 @@
.switch{position:relative;display:inline-block;width:60px;height:34px}.switch input{opacity:0;width:0;height:0}.slider{position:absolute;cursor:pointer;inset:0;background-color:#e5e7eb;transition:.4s}.dark .slider{background-color:#4b5563}input:checked+.slider{background-color:#4f46e5}.dark input:checked+.slider{background-color:#4f46e5}.slider:before{position:absolute;content:"";height:26px;width:26px;left:4px;bottom:4px;background-color:#fff;transition:.4s}.dark .slider:before{background-color:#e5e7eb}.slider.round{border-radius:34px}.slider.round:before{border-radius:50%}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin-slow{animation:spin 8s linear infinite}.transition-colors{transition-property:background-color,border-color,color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.3s}.custom-scrollbar::-webkit-scrollbar{width:6px}.custom-scrollbar::-webkit-scrollbar-track{background:transparent}.custom-scrollbar::-webkit-scrollbar-thumb{background-color:#cbd5e0;border-radius:3px}:is():hover{background-color:#a0aec0}:global(.dark) .custom-scrollbar::-webkit-scrollbar-thumb{background-color:#4a5568}:is():hover{background-color:#2d3748}
@@ -1,11 +0,0 @@
import{c as _,d as S,g as x,a as M,o as v,n as d,e as t,i as k,b as s,t as r,D,M as w,E as F,u as U,N as B,f as z,k as g,m,y as C,S as $}from"./index-14Tp7zPK.js";import{F as N}from"./file-CN_5aebv.js";import{H as T}from"./hard-drive-DvXIDK_-.js";/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const H=_("activity",[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]]);/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const V=_("users",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["path",{d:"M16 3.128a4 4 0 0 1 0 7.744",key:"16gr8j"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}]]),j={class:"flex items-center justify-between"},b=S({__name:"StatCard",props:{title:{},value:{},icon:{},iconColor:{},descriptionType:{default:"neutral"}},setup(f){const l=f,e=k("isDarkMode"),a=x(()=>({indigo:e?"bg-indigo-900":"bg-indigo-100",purple:e?"bg-purple-900":"bg-purple-100",green:e?"bg-green-900":"bg-green-100",blue:e?"bg-blue-900":"bg-blue-100"})[l.iconColor]),h=x(()=>({indigo:e?"text-indigo-400":"text-indigo-600",purple:e?"text-purple-400":"text-purple-600",green:e?"text-green-400":"text-green-600",blue:e?"text-blue-400":"text-blue-600"})[l.iconColor]),p=x(()=>({success:e?"text-green-400":"text-green-600",error:e?"text-red-400":"text-red-600",neutral:e?"text-gray-400":"text-gray-600"})[l.descriptionType]);return(i,o)=>(v(),M("div",{class:d(["p-6 rounded-lg shadow-md transition-colors duration-300",[t(e)?"bg-gray-800 bg-opacity-70":"bg-white"]])},[s("div",j,[s("div",null,[s("p",{class:d(["text-sm",[t(e)?"text-gray-400":"text-gray-600"]])},r(i.title),3),s("h3",{class:d(["text-2xl font-bold mt-1",[t(e)?"text-white":"text-gray-800"]])},r(i.value),3)]),s("div",{class:d(["p-3 rounded-full",a.value])},[(v(),D(w(i.icon),{class:d(["w-6 h-6",h.value])},null,8,["class"]))],2)]),s("p",{class:d(["text-sm mt-2",p.value])},[F(i.$slots,"description")],2)],2))}}),I={class:"p-6 overflow-y-auto custom-scrollbar"},L={class:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"},A={class:"ml-2"},E={class:"ml-2"},q={class:"text-sm mt-1"},J=S({__name:"DashboardView",setup(f){const l=k("isDarkMode"),{t:e}=U(),a=B({totalFiles:0,storageUsed:0,yesterdayCount:0,todayCount:0,yesterdaySize:0,todaySize:0,sysUptime:0}),h=o=>{const c=new Date().getTime()-o,u=Math.floor(c/(1440*60*1e3)),y=Math.floor(c%(1440*60*1e3)/(3600*1e3));return`${u}${y}小时`},p=o=>{const n=parseInt(o)/1024,c=n/1024,u=c/1024,y=u/1024;return y>1?`${y.toFixed(2)}TB`:u>1?`${u.toFixed(2)}GB`:c>1?`${c.toFixed(2)}MB`:n>1?`${n.toFixed(2)}KB`:`${o}B`},i=async()=>{const o=await $.getDashboard();o.detail&&(a.totalFiles=o.detail.totalFiles,a.storageUsed=p(o.detail.storageUsed.toString()),a.yesterdaySize=p(o.detail.yesterdaySize.toString()),a.todaySize=p(o.detail.todaySize.toString()),a.yesterdayCount=o.detail.yesterdayCount,a.todayCount=o.detail.todayCount,a.sysUptime=h(Number(o.detail.sysUptime)))};return z(()=>{i()}),(o,n)=>(v(),M("div",I,[s("h2",{class:d(["text-2xl font-bold mb-6",[t(l)?"text-white":"text-gray-800"]])},r(t(e)("admin.dashboard.title")),3),s("div",L,[g(b,{title:t(e)("admin.dashboard.totalFiles"),value:a.totalFiles,icon:t(N),"icon-color":"indigo","description-type":"success"},{description:m(()=>[s("span",null,r(t(e)("admin.dashboard.yesterday"))+""+r(a.yesterdayCount),1),s("span",A,r(t(e)("admin.dashboard.today"))+""+r(a.todayCount),1)]),_:1},8,["title","value","icon"]),g(b,{title:t(e)("admin.dashboard.storageSpace"),value:a.storageUsed,icon:t(T),"icon-color":"purple","description-type":"success"},{description:m(()=>[s("span",null,r(t(e)("admin.dashboard.yesterday"))+""+r(a.yesterdaySize),1),s("span",E,r(t(e)("admin.dashboard.today"))+""+r(a.todaySize),1)]),_:1},8,["title","value","icon"]),g(b,{title:t(e)("admin.dashboard.activeUsers"),value:"25",icon:t(V),"icon-color":"green","description-type":"error"},{description:m(()=>[s("span",null,r(t(e)("admin.dashboard.weeklyChange")),1)]),_:1},8,["title","icon"]),g(b,{title:t(e)("admin.dashboard.systemStatus"),value:t(e)("admin.dashboard.normal"),icon:t(H),"icon-color":"blue","description-type":"neutral"},{description:m(()=>[C(r(t(e)("admin.dashboard.serverUptime"))+": "+r(a.sysUptime),1)]),_:1},8,["title","value","icon"])]),s("div",{class:d(["mt-auto text-center py-4",[t(l)?"text-gray-400":"text-gray-600"]])},[n[1]||(n[1]=s("p",{class:"text-sm"}," 版本 v2.2.1 更新时间:2025-09-04 ",-1)),s("p",q,[C(" © "+r(new Date().getFullYear())+" ",1),n[0]||(n[0]=s("a",{href:"https://github.com/vastsa/FileCodeBox"},"FileCodeBox",-1))])],2)]))}});export{J as default};
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{K as y,r as c,V as u,g as b,d as w,l as x,a as h,o as S,b as e,n as d,e as i,i as A,k,h as I,p as D,v as N,y as T,t as _,W as M,B as O,_ as V}from"./index-14Tp7zPK.js";import{B as E}from"./box-CiSQlKtb.js";const B=y("admin",()=>{const p=c(localStorage.getItem(u.ADMIN_PASSWORD)||""),a=c(localStorage.getItem(u.TOKEN)||""),s=c(!1),l=c(null),n=b(()=>s.value&&!!a.value),f=o=>{p.value=o,localStorage.setItem(u.ADMIN_PASSWORD,o)},g=o=>{a.value=o,localStorage.setItem(u.TOKEN,o)},m=o=>{l.value=o,s.value=!0,g(o.token)};return{adminPassword:p,token:a,isLoggedIn:s,userInfo:l,isAuthenticated:n,updateAdminPassword:f,setToken:g,setUserInfo:m,login:o=>{m(o)},logout:()=>{p.value="",a.value="",s.value=!1,l.value=null,localStorage.removeItem(u.ADMIN_PASSWORD),localStorage.removeItem(u.TOKEN)},initAuth:()=>{const o=localStorage.getItem(u.TOKEN);o&&(a.value=o,s.value=!0)}}}),K=B,j={class:"mx-auto h-16 w-16 relative"},P={class:"rounded-md shadow-sm -space-y-px"},R=["disabled"],L=w({__name:"LoginView",setup(p){const a=x(),s=c(""),l=c(!1),n=A("isDarkMode"),f=K(),g=()=>{let r=!0;return s.value?s.value.length<6&&(a.showAlert("密码长度至少为6位","error"),r=!1):(a.showAlert("无效的密码","error"),r=!1),r},m=O(),v=async()=>{if(g()){l.value=!0;try{const r=await M.login(s.value);r.detail?.token?(f.setToken(r.detail.token),m.push("/admin")):a.showAlert("登录失败:未获取到有效令牌","error")}catch(r){const t=r&&typeof r=="object"&&"response"in r&&r.response?.data?.detail||"登录失败";a.showAlert(t,"error")}finally{l.value=!1}}};return(r,t)=>(S(),h("div",{class:d(["min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 transition-colors duration-200 relative overflow-hidden",i(n)?"bg-gray-900":"bg-gray-50"])},[t[6]||(t[6]=e("div",{class:"absolute inset-0 z-0"},[e("div",{class:"cyber-grid"}),e("div",{class:"floating-particles"})],-1)),e("div",{class:d(["max-w-md w-full space-y-8 backdrop-blur-lg bg-opacity-20 p-8 rounded-xl border border-opacity-20",[i(n)?"bg-gray-800 border-gray-600":"bg-white/70 border-gray-200"]])},[e("div",null,[e("div",j,[t[1]||(t[1]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full animate-spin-slow"},null,-1)),t[2]||(t[2]=e("div",{class:"absolute -inset-2 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full opacity-50 blur-md animate-pulse"},null,-1)),e("div",{class:d(["absolute inset-1 rounded-full flex items-center justify-center",i(n)?"bg-gray-800":"bg-white"])},[k(i(E),{class:d(["h-8 w-8",i(n)?"text-cyan-400":"text-cyan-600"])},null,8,["class"])],2)]),e("h2",{class:d(["mt-6 text-center text-3xl font-extrabold",i(n)?"text-white":"text-gray-900"])}," 登录 ",2)]),e("form",{class:"mt-8 space-y-6",onSubmit:I(v,["prevent"])},[t[5]||(t[5]=e("input",{type:"hidden",name:"remember",value:"true"},null,-1)),e("div",P,[e("div",null,[t[3]||(t[3]=e("label",{for:"password",class:"sr-only"},"密码",-1)),D(e("input",{id:"password",name:"password",type:"password",autocomplete:"current-password",required:"","onUpdate:modelValue":t[0]||(t[0]=o=>s.value=o),class:d(["appearance-none rounded-t-md relative block w-full px-4 py-3 border transition-all duration-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm backdrop-blur-sm",i(n)?"bg-gray-800/50 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500":"bg-white/50 border-gray-300 text-gray-900 hover:border-gray-400"]),placeholder:"密码"},null,2),[[N,s.value]])])]),e("div",null,[e("button",{type:"submit",class:d(["group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 shadow-lg hover:shadow-cyan-500/50",i(n)?"bg-gradient-to-r from-cyan-500 to-purple-500 hover:from-cyan-600 hover:to-purple-600":"bg-gradient-to-r from-cyan-600 to-purple-600 hover:from-cyan-700 hover:to-purple-700",l.value?"opacity-75 cursor-not-allowed":""]),disabled:l.value},[t[4]||(t[4]=e("span",{class:"absolute left-0 inset-y-0 flex items-center pl-3"},null,-1)),T(" "+_(l.value?"登录中...":"登录"),1)],10,R)])],32)],2)],2))}}),C=V(L,[["__scopeId","data-v-894571a2"]]);export{C as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.border-progress-container[data-v-2fbf5085]{position:relative;width:100%;height:100%}.border-progress-canvas[data-v-2fbf5085]{position:absolute;top:0;left:0;width:100%;height:100%;transition:all .3s ease}.custom-scrollbar[data-v-bebdfe92]{scrollbar-width:thin;scrollbar-color:rgba(156,163,175,.3) rgba(243,244,246,.5)}.custom-scrollbar[data-v-bebdfe92]::-webkit-scrollbar{width:6px;height:6px}.custom-scrollbar[data-v-bebdfe92]::-webkit-scrollbar-track{background:#f3f4f680;border-radius:3px}.custom-scrollbar[data-v-bebdfe92]::-webkit-scrollbar-thumb{background-color:#9ca3af80;border-radius:3px;-webkit-transition:background-color .3s;transition:background-color .3s}.custom-scrollbar[data-v-bebdfe92]::-webkit-scrollbar-thumb:hover{background-color:#9ca3afb3}[data-v-bebdfe92] [class*=dark] .custom-scrollbar{scrollbar-color:rgba(75,85,99,.5) rgba(31,41,55,.5)}[data-v-bebdfe92] [class*=dark] .custom-scrollbar::-webkit-scrollbar-track{background:#1f293780}[data-v-bebdfe92] [class*=dark] .custom-scrollbar::-webkit-scrollbar-thumb{background-color:#4b556380}[data-v-bebdfe92] [class*=dark] .custom-scrollbar::-webkit-scrollbar-thumb:hover{background-color:#4b5563b3}.fade-enter-active[data-v-ebff2eb6],.fade-leave-active[data-v-ebff2eb6]{transition:opacity .3s ease,transform .3s ease}.fade-enter-from[data-v-ebff2eb6],.fade-leave-to[data-v-ebff2eb6]{opacity:0;transform:translateY(10px)}@media (min-width: 640px){.sm\:w-120[data-v-ebff2eb6]{width:30rem}}.fade-enter-to[data-v-ebff2eb6],.fade-leave-from[data-v-ebff2eb6]{opacity:1;transform:translateY(0)}.drawer-enter-active[data-v-ebff2eb6],.drawer-leave-active[data-v-ebff2eb6]{transition:transform .3s ease}.drawer-enter-from[data-v-ebff2eb6],.drawer-leave-to[data-v-ebff2eb6]{transform:translate(100%)}.list-enter-active[data-v-ebff2eb6],.list-leave-active[data-v-ebff2eb6]{transition:all .5s ease}.list-enter-from[data-v-ebff2eb6],.list-leave-to[data-v-ebff2eb6]{opacity:0;transform:translate(30px)}select option[data-v-ebff2eb6]{padding:8px;margin:4px;border-radius:6px}select option[data-v-ebff2eb6]:checked{background:linear-gradient(to right,#6366f180,#a855f780)!important;color:#fff!important}.dark select option[data-v-ebff2eb6]:checked{background:linear-gradient(to right,#6366f1b3,#a855f7b3)!important}select option[data-v-ebff2eb6]:hover{background-color:#6366f11a}.dark select option[data-v-ebff2eb6]:hover{background-color:#6366f133}.custom-scrollbar[data-v-ebff2eb6]{scrollbar-width:thin;scrollbar-color:rgba(156,163,175,.4) rgba(243,244,246,.3)}.custom-scrollbar[data-v-ebff2eb6]::-webkit-scrollbar{width:8px;height:8px}.custom-scrollbar[data-v-ebff2eb6]::-webkit-scrollbar-track{background:#f3f4f64d;border-radius:6px;margin:4px}.custom-scrollbar[data-v-ebff2eb6]::-webkit-scrollbar-thumb{background:linear-gradient(135deg,#6366f199,#a855f799);border-radius:6px;border:1px solid rgba(255,255,255,.2);-webkit-transition:all .3s ease;transition:all .3s ease}.custom-scrollbar[data-v-ebff2eb6]::-webkit-scrollbar-thumb:hover{background:linear-gradient(135deg,#6366f1cc,#a855f7cc);transform:scale(1.1)}.custom-scrollbar[data-v-ebff2eb6]::-webkit-scrollbar-corner{background:transparent}.dark .custom-scrollbar[data-v-ebff2eb6]{scrollbar-color:rgba(75,85,99,.6) rgba(31,41,55,.4)}.dark .custom-scrollbar[data-v-ebff2eb6]::-webkit-scrollbar-track{background:#1f293766}.dark .custom-scrollbar[data-v-ebff2eb6]::-webkit-scrollbar-thumb{background:linear-gradient(135deg,#6366f1b3,#a855f7b3);border:1px solid rgba(255,255,255,.1)}.dark .custom-scrollbar[data-v-ebff2eb6]::-webkit-scrollbar-thumb:hover{background:linear-gradient(135deg,#6366f1e6,#a855f7e6)}
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
import{c as a}from"./index-14Tp7zPK.js";/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const e=a("box",[["path",{d:"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z",key:"hh9hay"}],["path",{d:"m3.3 7 8.7 5 8.7-5",key:"g66t2b"}],["path",{d:"M12 22V12",key:"d0xqtd"}]]);export{e as B};
-6
View File
@@ -1,6 +0,0 @@
import{c as a}from"./index-14Tp7zPK.js";/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const t=a("file",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}]]);export{t as F};
@@ -1,6 +0,0 @@
import{c as e}from"./index-14Tp7zPK.js";/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const a=e("hard-drive",[["line",{x1:"22",x2:"2",y1:"12",y2:"12",key:"1y58io"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}],["line",{x1:"6",x2:"6.01",y1:"16",y2:"16",key:"sgf278"}],["line",{x1:"10",x2:"10.01",y1:"16",y2:"16",key:"1l4acy"}]]);export{a as H};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-6
View File
@@ -1,6 +0,0 @@
import{c as a}from"./index-14Tp7zPK.js";/**
* @license lucide-vue-next v0.535.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const e=a("trash",[["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]]);export{e as T};
+2 -2
View File
@@ -13,8 +13,8 @@
<link rel="manifest" href="/assets/manifest.json" /> <link rel="manifest" href="/assets/manifest.json" />
<meta name="github" content="https://github.com/vastsa/FileCodeBox" /> <meta name="github" content="https://github.com/vastsa/FileCodeBox" />
<title>{{title}}</title> <title>{{title}}</title>
<script type="module" crossorigin src="/assets/index-14Tp7zPK.js"></script> <script type="module" crossorigin src="/assets/index-PTyx6WsQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cd5a1Wuq.css"> <link rel="stylesheet" crossorigin href="/assets/index-pbbNlpl9.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>