feat: 新增webdav存储

This commit is contained in:
Lan
2025-02-09 20:25:23 +08:00
parent 0add20e722
commit 037949b715
20 changed files with 281 additions and 82 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
import datetime import datetime
import uuid
import os import os
import uuid
from fastapi import UploadFile, HTTPException from fastapi import UploadFile, HTTPException
from typing import Tuple, Optional from typing import Optional, Tuple
from apps.base.dependencies import IPRateLimit from apps.base.dependencies import IPRateLimit
from apps.base.models import FileCodes from apps.base.models import FileCodes
+5 -1
View File
@@ -135,4 +135,8 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit['error']
if not has: if not has:
return APIResponse(code=404, detail='文件不存在') return APIResponse(code=404, detail='文件不存在')
return APIResponse(detail=file_code.text) if file_code.text else await file_storage.get_file_response(file_code) return (
APIResponse(detail=file_code.text)
if file_code.text
else await file_storage.get_file_response(file_code)
)
+3
View File
@@ -40,6 +40,9 @@ DEFAULT_CONFIG = {
'uploadSize': 1024 * 1024 * 10, 'uploadSize': 1024 * 1024 * 10,
'expireStyle': ['day', 'hour', 'minute', 'forever', 'count'], 'expireStyle': ['day', 'hour', 'minute', 'forever', 'count'],
'uploadMinute': 1, 'uploadMinute': 1,
'webdav_url': '',
'webdav_password': '',
'webdav_username': '',
'opacity': 0.9, 'opacity': 0.9,
'background': '', 'background': '',
'uploadCount': 10, 'uploadCount': 10,
+254 -60
View File
@@ -3,7 +3,7 @@
# @File : storage.py # @File : storage.py
# @Software: PyCharm # @Software: PyCharm
from typing import Optional from typing import Optional
from urllib.parse import quote, unquote
import aiohttp import aiohttp
import asyncio import asyncio
from pathlib import Path from pathlib import Path
@@ -22,11 +22,13 @@ from fastapi.responses import FileResponse
class FileStorageInterface: class FileStorageInterface:
_instance: Optional['FileStorageInterface'] = None _instance: Optional["FileStorageInterface"] = None
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if cls._instance is None: if cls._instance is None:
cls._instance = super(FileStorageInterface, cls).__new__(cls, *args, **kwargs) cls._instance = super(FileStorageInterface, cls).__new__(
cls, *args, **kwargs
)
return cls._instance return cls._instance
async def save_file(self, file: UploadFile, save_path: str): async def save_file(self, file: UploadFile, save_path: str):
@@ -66,7 +68,7 @@ class SystemFileStorage(FileStorageInterface):
self.root_path = data_root self.root_path = data_root
def _save(self, file, save_path): def _save(self, file, save_path):
with open(save_path, 'wb') as f: with open(save_path, "wb") as f:
chunk = file.read(self.chunk_size) chunk = file.read(self.chunk_size)
while chunk: while chunk:
f.write(chunk) f.write(chunk)
@@ -89,7 +91,7 @@ class SystemFileStorage(FileStorageInterface):
async def get_file_response(self, file_code: FileCodes): async def get_file_response(self, file_code: FileCodes):
file_path = self.root_path / await file_code.get_file_path() file_path = self.root_path / await file_code.get_file_path()
if not file_path.exists(): if not file_path.exists():
return APIResponse(code=404, detail='文件已过期删除') return APIResponse(code=404, detail="文件已过期删除")
return FileResponse(file_path, filename=file_code.prefix + file_code.suffix) return FileResponse(file_path, filename=file_code.prefix + file_code.suffix)
@@ -101,30 +103,62 @@ class S3FileStorage(FileStorageInterface):
self.s3_hostname = settings.s3_hostname self.s3_hostname = settings.s3_hostname
self.region_name = settings.s3_region_name self.region_name = settings.s3_region_name
self.signature_version = settings.s3_signature_version self.signature_version = settings.s3_signature_version
self.endpoint_url = settings.s3_endpoint_url or f'https://{self.s3_hostname}' self.endpoint_url = settings.s3_endpoint_url or f"https://{self.s3_hostname}"
self.aws_session_token = settings.aws_session_token self.aws_session_token = settings.aws_session_token
self.proxy = settings.s3_proxy self.proxy = settings.s3_proxy
self.session = aioboto3.Session(aws_access_key_id=self.access_key_id, aws_secret_access_key=self.secret_access_key) self.session = aioboto3.Session(
aws_access_key_id=self.access_key_id,
aws_secret_access_key=self.secret_access_key,
)
if not settings.s3_endpoint_url: if not settings.s3_endpoint_url:
self.endpoint_url = f'https://{self.s3_hostname}' self.endpoint_url = f"https://{self.s3_hostname}"
else: else:
# 如果提供了 s3_endpoint_url,则优先使用它 # 如果提供了 s3_endpoint_url,则优先使用它
self.endpoint_url = settings.s3_endpoint_url self.endpoint_url = settings.s3_endpoint_url
async def save_file(self, file: UploadFile, save_path: str): async def save_file(self, file: UploadFile, save_path: str):
async with self.session.client("s3", endpoint_url=self.endpoint_url, aws_session_token=self.aws_session_token, region_name=self.region_name, async with self.session.client(
config=Config(signature_version=self.signature_version)) as s3: "s3",
await s3.put_object(Bucket=self.bucket_name, Key=save_path, Body=await file.read(), ContentType=file.content_type) 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:
await s3.put_object(
Bucket=self.bucket_name,
Key=save_path,
Body=await file.read(),
ContentType=file.content_type,
)
async def delete_file(self, file_code: FileCodes): async def delete_file(self, file_code: FileCodes):
async with self.session.client("s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version)) as s3: async with self.session.client(
await s3.delete_object(Bucket=self.bucket_name, Key=await file_code.get_file_path()) "s3",
endpoint_url=self.endpoint_url,
region_name=self.region_name,
config=Config(signature_version=self.signature_version),
) as s3:
await s3.delete_object(
Bucket=self.bucket_name, Key=await file_code.get_file_path()
)
async def get_file_response(self, file_code: FileCodes): async def get_file_response(self, file_code: FileCodes):
try: try:
filename = file_code.prefix + file_code.suffix filename = file_code.prefix + file_code.suffix
async with self.session.client("s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version)) as s3: async with self.session.client(
link = await s3.generate_presigned_url('get_object', Params={'Bucket': self.bucket_name, 'Key': await file_code.get_file_path()}, ExpiresIn=3600) "s3",
endpoint_url=self.endpoint_url,
region_name=self.region_name,
config=Config(signature_version=self.signature_version),
) as s3:
link = await s3.generate_presigned_url(
"get_object",
Params={
"Bucket": self.bucket_name,
"Key": await file_code.get_file_path(),
},
ExpiresIn=3600,
)
tmp = io.BytesIO() tmp = io.BytesIO()
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(link) as resp: async with session.get(link) as resp:
@@ -132,18 +166,36 @@ class S3FileStorage(FileStorageInterface):
tmp.seek(0) tmp.seek(0)
content = tmp.read() content = tmp.read()
tmp.close() tmp.close()
return Response(content, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'}) return Response(
content,
media_type="application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'
},
)
except Exception: except Exception:
raise HTTPException(status_code=503, detail='服务代理下载异常,请稍后再试') raise HTTPException(status_code=503, detail="服务代理下载异常,请稍后再试")
async def get_file_url(self, file_code: FileCodes): async def get_file_url(self, file_code: FileCodes):
if file_code.prefix == '文本分享': if file_code.prefix == "文本分享":
return file_code.text return file_code.text
if self.proxy: if self.proxy:
return await get_file_url(file_code.code) return await get_file_url(file_code.code)
else: else:
async with self.session.client("s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version)) as s3: async with self.session.client(
result = await s3.generate_presigned_url('get_object', Params={'Bucket': self.bucket_name, 'Key': await file_code.get_file_path()}, ExpiresIn=3600) "s3",
endpoint_url=self.endpoint_url,
region_name=self.region_name,
config=Config(signature_version=self.signature_version),
) as s3:
result = await s3.generate_presigned_url(
"get_object",
Params={
"Bucket": self.bucket_name,
"Key": await file_code.get_file_path(),
},
ExpiresIn=3600,
)
return result return result
@@ -152,9 +204,11 @@ class OneDriveFileStorage(FileStorageInterface):
try: try:
import msal import msal
from office365.graph_client import GraphClient from office365.graph_client import GraphClient
from office365.runtime.client_request_exception import ClientRequestException from office365.runtime.client_request_exception import (
ClientRequestException,
)
except ImportError: except ImportError:
raise ImportError('请先安装`msal`和`Office365-REST-Python-Client`') raise ImportError("请先安装`msal`和`Office365-REST-Python-Client`")
self.msal = msal self.msal = msal
self.domain = settings.onedrive_domain self.domain = settings.onedrive_domain
self.client_id = settings.onedrive_client_id self.client_id = settings.onedrive_client_id
@@ -165,36 +219,45 @@ class OneDriveFileStorage(FileStorageInterface):
try: try:
client = GraphClient(self.acquire_token_pwd) client = GraphClient(self.acquire_token_pwd)
self.root_path = client.me.drive.root.get_by_path(settings.onedrive_root_path).get().execute_query() self.root_path = (
client.me.drive.root.get_by_path(settings.onedrive_root_path)
.get()
.execute_query()
)
except ClientRequestException as e: except ClientRequestException as e:
if e.code == 'itemNotFound': if e.code == "itemNotFound":
client.me.drive.root.create_folder(settings.onedrive_root_path) client.me.drive.root.create_folder(settings.onedrive_root_path)
self.root_path = client.me.drive.root.get_by_path(settings.onedrive_root_path).get().execute_query() self.root_path = (
client.me.drive.root.get_by_path(settings.onedrive_root_path)
.get()
.execute_query()
)
else: else:
raise e raise e
except Exception as e: except Exception as e:
raise Exception('OneDrive验证失败,请检查配置是否正确\n' + str(e)) raise Exception("OneDrive验证失败,请检查配置是否正确\n" + str(e))
def acquire_token_pwd(self): def acquire_token_pwd(self):
authority_url = f'https://login.microsoftonline.com/{self.domain}' authority_url = f"https://login.microsoftonline.com/{self.domain}"
app = self.msal.PublicClientApplication( app = self.msal.PublicClientApplication(
authority=authority_url, authority=authority_url, client_id=self.client_id
client_id=self.client_id )
result = app.acquire_token_by_username_password(
username=self.username,
password=self.password,
scopes=["https://graph.microsoft.com/.default"],
) )
result = app.acquire_token_by_username_password(username=self.username,
password=self.password,
scopes=['https://graph.microsoft.com/.default'])
return result return result
def _get_path_str(self, path): def _get_path_str(self, path):
if isinstance(path, str): if isinstance(path, str):
path = path.replace('\\', '/').replace('//', '/').split('/') path = path.replace("\\", "/").replace("//", "/").split("/")
elif isinstance(path, Path): elif isinstance(path, Path):
path = str(path).replace('\\', '/').replace('//', '/').split('/') path = str(path).replace("\\", "/").replace("//", "/").split("/")
else: else:
raise TypeError('path must be str or Path') raise TypeError("path must be str or Path")
path[-1] = path[-1].split('.')[0] path[-1] = path[-1].split(".")[0]
return '/'.join(path) return "/".join(path)
def _save(self, file, save_path): def _save(self, file, save_path):
content = file.file.read() content = file.file.read()
@@ -210,7 +273,7 @@ class OneDriveFileStorage(FileStorageInterface):
try: try:
self.root_path.get_by_path(path).delete_object().execute_query() self.root_path.get_by_path(path).delete_object().execute_query()
except self._ClientRequestException as e: except self._ClientRequestException as e:
if e.code == 'itemNotFound': if e.code == "itemNotFound":
pass pass
else: else:
raise e raise e
@@ -219,23 +282,29 @@ class OneDriveFileStorage(FileStorageInterface):
await asyncio.to_thread(self._delete, await file_code.get_file_path()) await asyncio.to_thread(self._delete, await file_code.get_file_path())
def _convert_link_to_download_link(self, link): def _convert_link_to_download_link(self, link):
p1 = re.search(r'https:\/\/(.+)\.sharepoint\.com', link).group(1) p1 = re.search(r"https:\/\/(.+)\.sharepoint\.com", link).group(1)
p2 = re.search(r'personal\/(.+)\/', link).group(1) p2 = re.search(r"personal\/(.+)\/", link).group(1)
p3 = re.search(rf'{p2}\/(.+)', link).group(1) p3 = re.search(rf"{p2}\/(.+)", link).group(1)
return f'https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}' return f"https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}"
def _get_file_url(self, save_path, name): def _get_file_url(self, save_path, name):
path = self._get_path_str(save_path) path = self._get_path_str(save_path)
remote_file = self.root_path.get_by_path(path + '/' + name) remote_file = self.root_path.get_by_path(path + "/" + name)
expiration_datetime = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(hours=1) expiration_datetime = datetime.datetime.now(
tz=datetime.timezone.utc
) + datetime.timedelta(hours=1)
expiration_datetime = expiration_datetime.strftime("%Y-%m-%dT%H:%M:%SZ") expiration_datetime = expiration_datetime.strftime("%Y-%m-%dT%H:%M:%SZ")
premission = remote_file.create_link("view", "anonymous", expiration_datetime=expiration_datetime).execute_query() premission = remote_file.create_link(
"view", "anonymous", expiration_datetime=expiration_datetime
).execute_query()
return self._convert_link_to_download_link(premission.link.webUrl) return self._convert_link_to_download_link(premission.link.webUrl)
async def get_file_response(self, file_code: FileCodes): async def get_file_response(self, file_code: FileCodes):
try: try:
filename = file_code.prefix + file_code.suffix filename = file_code.prefix + file_code.suffix
link = await asyncio.to_thread(self._get_file_url, await file_code.get_file_path(), filename) link = await asyncio.to_thread(
self._get_file_url, await file_code.get_file_path(), filename
)
tmp = io.BytesIO() tmp = io.BytesIO()
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(link) as resp: async with session.get(link) as resp:
@@ -243,15 +312,25 @@ class OneDriveFileStorage(FileStorageInterface):
tmp.seek(0) tmp.seek(0)
content = tmp.read() content = tmp.read()
tmp.close() tmp.close()
return Response(content, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'}) return Response(
content,
media_type="application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'
},
)
except Exception: except Exception:
raise HTTPException(status_code=503, detail='服务代理下载异常,请稍后再试') raise HTTPException(status_code=503, detail="服务代理下载异常,请稍后再试")
async def get_file_url(self, file_code: FileCodes): async def get_file_url(self, file_code: FileCodes):
if self.proxy: if self.proxy:
return await get_file_url(file_code.code) return await get_file_url(file_code.code)
else: else:
return await asyncio.to_thread(self._get_file_url, await file_code.get_file_path(), f'{file_code.prefix}{file_code.suffix}') return await asyncio.to_thread(
self._get_file_url,
await file_code.get_file_path(),
f"{file_code.prefix}{file_code.suffix}",
)
class OpenDALFileStorage(FileStorageInterface): class OpenDALFileStorage(FileStorageInterface):
@@ -263,10 +342,12 @@ class OpenDALFileStorage(FileStorageInterface):
self.service = settings.opendal_scheme self.service = settings.opendal_scheme
service_settings = {} service_settings = {}
for key, value in settings.items(): for key, value in settings.items():
if key.startswith('opendal_' + self.service): if key.startswith("opendal_" + self.service):
setting_name = key.split('_', 2)[2] setting_name = key.split("_", 2)[2]
service_settings[setting_name] = value service_settings[setting_name] = value
self.operator = opendal.AsyncOperator(settings.opendal_scheme, **service_settings) self.operator = opendal.AsyncOperator(
settings.opendal_scheme, **service_settings
)
async def save_file(self, file: UploadFile, save_path: str): async def save_file(self, file: UploadFile, save_path: str):
await self.operator.write(save_path, file.file.read()) await self.operator.write(save_path, file.file.read())
@@ -281,18 +362,131 @@ class OpenDALFileStorage(FileStorageInterface):
try: try:
filename = file_code.prefix + file_code.suffix filename = file_code.prefix + file_code.suffix
content = await self.operator.read(await file_code.get_file_path()) content = await self.operator.read(await file_code.get_file_path())
headers = { headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
"Content-Disposition": f'attachment; filename="{filename}"' return Response(
} content, headers=headers, media_type="application/octet-stream"
return Response(content, headers=headers, media_type="application/octet-stream") )
except Exception as e: except Exception as e:
print(e, file=sys.stderr) print(e, file=sys.stderr)
raise HTTPException(status_code=404, detail="文件已过期删除") raise HTTPException(status_code=404, detail="文件已过期删除")
class WebDAVFileStorage(FileStorageInterface):
_instance: Optional["WebDAVFileStorage"] = None
def __init__(self):
if not hasattr(self, "_initialized"):
self.base_url = settings.webdav_url.rstrip("/") + "/"
self.auth = aiohttp.BasicAuth(
login=settings.webdav_username, password=settings.webdav_password
)
self._initialized = True
def _build_url(self, path: str) -> str:
encoded_path = quote(str(path).lstrip("/"))
return f"{self.base_url}{encoded_path}"
async def _mkdir_p(self, directory_path: str):
"""递归创建目录(类似mkdir -p"""
path_obj = Path(unquote(directory_path))
current_path = ""
async with aiohttp.ClientSession(auth=self.auth) as session:
# 逐级检查目录是否存在
for part in path_obj.parts:
current_path = str(Path(current_path) / part)
url = self._build_url(current_path)
# 检查目录是否存在
async with session.head(url) as resp:
if resp.status == 404:
# 创建目录
async with session.request("MKCOL", url) as mkcol_resp:
if mkcol_resp.status not in (200, 201, 409):
content = await mkcol_resp.text()
raise HTTPException(
status_code=mkcol_resp.status,
detail=f"目录创建失败: {content[:200]}",
)
async def save_file(self, file: UploadFile, save_path: str):
"""保存文件(自动创建目录)"""
# 分离文件名和目录路径
path_obj = Path(save_path)
directory_path = str(path_obj.parent)
file_name = path_obj.name
try:
# 先创建目录结构
await self._mkdir_p(directory_path)
# 上传文件
url = self._build_url(save_path)
async with aiohttp.ClientSession(auth=self.auth) as session:
content = await file.read() # 注意:大文件需要分块读取
async with session.put(
url, data=content, headers={"Content-Type": file.content_type}
) as resp:
if resp.status not in (200, 201, 204):
content = await resp.text()
raise HTTPException(
status_code=resp.status,
detail=f"文件上传失败: {content[:200]}",
)
except aiohttp.ClientError as e:
raise HTTPException(status_code=503, detail=f"WebDAV连接异常: {str(e)}")
async def delete_file(self, file_code: FileCodes):
"""删除WebDAV文件"""
file_path = await file_code.get_file_path()
url = self._build_url(file_path)
try:
async with aiohttp.ClientSession(auth=self.auth) as session:
async with session.delete(url) as resp:
if resp.status not in (200, 204):
content = await resp.text()
raise HTTPException(
status_code=resp.status,
detail=f"WebDAV删除失败: {content[:200]}",
)
except aiohttp.ClientError as e:
raise HTTPException(status_code=503, detail=f"WebDAV连接异常: {str(e)}")
async def get_file_url(self, file_code: FileCodes):
return await get_file_url(file_code.code)
async def get_file_response(self, file_code: FileCodes):
"""获取文件响应(代理模式)"""
try:
filename = file_code.prefix + file_code.suffix
url = self._build_url(await file_code.get_file_path())
async with aiohttp.ClientSession(auth=self.auth) as session:
async with session.get(url) as resp:
if resp.status != 200:
raise HTTPException(
status_code=resp.status,
detail=f"文件获取失败: {await resp.text()}",
)
# 读取内容到内存
content = await resp.read()
return Response(
content=content,
media_type=resp.headers.get(
"Content-Type", "application/octet-stream"
),
headers={
"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode()}"'
},
)
except aiohttp.ClientError as e:
raise HTTPException(status_code=503, detail=f"WebDAV连接异常: {str(e)}")
storages = { storages = {
'local': SystemFileStorage, "local": SystemFileStorage,
's3': S3FileStorage, "s3": S3FileStorage,
'onedrive': OneDriveFileStorage, "onedrive": OneDriveFileStorage,
'opendal': OpenDALFileStorage, "opendal": OpenDALFileStorage,
"webdav": WebDAVFileStorage,
} }
-1
View File
@@ -16,7 +16,6 @@ from core.utils import get_now
async def delete_expire_files(): async def delete_expire_files():
file_storage: FileStorageInterface = storages[settings.file_storage]() file_storage: FileStorageInterface = storages[settings.file_storage]()
print(settings.file_storage)
while True: while True:
try: try:
# 遍历 share目录下的所有文件夹,删除空的文件夹,并判断父目录是否为空,如果为空也删除 # 遍历 share目录下的所有文件夹,删除空的文件夹,并判断父目录是否为空,如果为空也删除
+2 -4
View File
@@ -7,9 +7,7 @@ import hashlib
import random import random
import string import string
import time import time
from core.settings import settings
from apps.base.dependencies import IPRateLimit
async def get_random_num(): async def get_random_num():
""" """
@@ -46,7 +44,7 @@ async def get_select_token(code: str):
:param code: :param code:
:return: :return:
""" """
token = "123456" token = settings.admin_token
return hashlib.sha256(f"{code}{int(time.time() / 1000)}000{token}".encode()).hexdigest() return hashlib.sha256(f"{code}{int(time.time() / 1000)}000{token}".encode()).hexdigest()
@@ -1,4 +1,4 @@
import{c as i,B as k,r as h,o as u,I as v,d as x,e as t,n as o,g as e,i as w,f as n,X as _,F as M,q as C,p as z,y as B,J as L,m as D,K as F,t as I,z as d}from"./index-BIfJi_O-.js";import{B as j}from"./box-C9xlYTT6.js";/** import{c as i,B as k,r as h,o as u,I as v,d as x,e as t,n as o,g as e,i as w,f as n,X as _,F as M,q as C,p as z,y as B,J as L,m as D,K as F,t as I,z as d}from"./index-CiI2cp0V.js";import{B as j}from"./box-BA8SMkDM.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as g,B as v,H as w,o as _,d as u,e as t,n as a,g as e,i as k,t as i,f as y,F as U,q as F,A as M,z as p,J as z,K as C}from"./index-BIfJi_O-.js";import{F as m}from"./file-AHrKtGwY.js";import{H as D,T as S}from"./trash-wtxrZ93m.js";/** import{c as g,B as v,H as w,o as _,d as u,e as t,n as a,g as e,i as k,t as i,f as y,F as U,q as F,A as M,z as p,J as z,K as C}from"./index-CiI2cp0V.js";import{F as m}from"./file-CWusGEpI.js";import{H as D,T as S}from"./trash-dDHutUhM.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as V,B as T,u as q,r as _,b as k,d as c,e as t,n as o,g as a,i as A,j as P,f as m,v as H,m as I,F as v,q as w,t as d,A as S,z as g}from"./index-BIfJi_O-.js";import{F as L}from"./file-AHrKtGwY.js";/** import{c as V,B as T,u as q,r as _,b as k,d as c,e as t,n as o,g as a,i as A,j as P,f as m,v as H,m as I,F as v,q as w,t as d,A as S,z as g}from"./index-CiI2cp0V.js";import{F as L}from"./file-CWusGEpI.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{G as y,r as u,B as v,u as b,d as w,e,n as l,g as o,i as x,f as h,h as k,j as S,v as A,m as V,t as B,y as D,z as j,A as P,_}from"./index-BIfJi_O-.js";import{B as z}from"./box-C9xlYTT6.js";const M=y("adminData",()=>{const d=u(localStorage.getItem("adminPassword")||"");function n(t){d.value=t,localStorage.setItem("token",t)}return{adminPassword:d,updateAdminPwd:n}}),I={class:"mx-auto h-16 w-16 relative"},L={class:"rounded-md shadow-sm -space-y-px"},N=["disabled"],T=v({__name:"LoginView",setup(d){const n=b(),t=u(""),i=u(!1),s=x("isDarkMode"),c=M(),p=()=>{let a=!0;return t.value?t.value.length<6&&(n.showAlert("密码长度至少为6位","error"),a=!1):(n.showAlert("无效的密码","error"),a=!1),a},m=D(),f=async()=>{if(p()){c.updateAdminPwd(t.value),P.post("/admin/login",{password:t.value}).then(()=>{m.push("/admin")}).catch(a=>{n.showAlert(a.response.data.detail,"error")}),i.value=!0;try{await new Promise(a=>setTimeout(a,2e3))}catch{}finally{i.value=!1}}};return(a,r)=>(j(),w("div",{class:l(["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",o(s)?"bg-gray-900":"bg-gray-50"])},[r[6]||(r[6]=e("div",{class:"absolute inset-0 z-0"},[e("div",{class:"cyber-grid"}),e("div",{class:"floating-particles"})],-1)),e("div",{class:l(["max-w-md w-full space-y-8 backdrop-blur-lg bg-opacity-20 p-8 rounded-xl border border-opacity-20",[o(s)?"bg-gray-800 border-gray-600":"bg-white/70 border-gray-200"]])},[e("div",null,[e("div",I,[r[1]||(r[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)),r[2]||(r[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:l(["absolute inset-1 rounded-full flex items-center justify-center",o(s)?"bg-gray-800":"bg-white"])},[h(o(z),{class:l(["h-8 w-8",o(s)?"text-cyan-400":"text-cyan-600"])},null,8,["class"])],2)]),e("h2",{class:l(["mt-6 text-center text-3xl font-extrabold",o(s)?"text-white":"text-gray-900"])}," 登录 ",2)]),e("form",{class:"mt-8 space-y-6",onSubmit:k(f,["prevent"])},[r[5]||(r[5]=e("input",{type:"hidden",name:"remember",value:"true"},null,-1)),e("div",L,[e("div",null,[r[3]||(r[3]=e("label",{for:"password",class:"sr-only"},"密码",-1)),S(e("input",{id:"password",name:"password",type:"password",autocomplete:"current-password",required:"","onUpdate:modelValue":r[0]||(r[0]=g=>t.value=g),class:l(["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",o(s)?"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),[[A,t.value]])])]),e("div",null,[e("button",{type:"submit",class:l(["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",o(s)?"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",i.value?"opacity-75 cursor-not-allowed":""]),disabled:i.value},[r[4]||(r[4]=e("span",{class:"absolute left-0 inset-y-0 flex items-center pl-3"},null,-1)),V(" "+B(i.value?"登录中...":"登录"),1)],10,N)])],32)],2)],2))}}),E=_(T,[["__scopeId","data-v-2e50c3fa"]]);export{E as default}; import{G as y,r as u,B as v,u as b,d as w,e,n as l,g as o,i as x,f as h,h as k,j as S,v as A,m as V,t as B,y as D,z as j,A as P,_}from"./index-CiI2cp0V.js";import{B as z}from"./box-BA8SMkDM.js";const M=y("adminData",()=>{const d=u(localStorage.getItem("adminPassword")||"");function n(t){d.value=t,localStorage.setItem("token",t)}return{adminPassword:d,updateAdminPwd:n}}),I={class:"mx-auto h-16 w-16 relative"},L={class:"rounded-md shadow-sm -space-y-px"},N=["disabled"],T=v({__name:"LoginView",setup(d){const n=b(),t=u(""),i=u(!1),s=x("isDarkMode"),c=M(),p=()=>{let a=!0;return t.value?t.value.length<6&&(n.showAlert("密码长度至少为6位","error"),a=!1):(n.showAlert("无效的密码","error"),a=!1),a},m=D(),f=async()=>{if(p()){c.updateAdminPwd(t.value),P.post("/admin/login",{password:t.value}).then(()=>{m.push("/admin")}).catch(a=>{n.showAlert(a.response.data.detail,"error")}),i.value=!0;try{await new Promise(a=>setTimeout(a,2e3))}catch{}finally{i.value=!1}}};return(a,r)=>(j(),w("div",{class:l(["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",o(s)?"bg-gray-900":"bg-gray-50"])},[r[6]||(r[6]=e("div",{class:"absolute inset-0 z-0"},[e("div",{class:"cyber-grid"}),e("div",{class:"floating-particles"})],-1)),e("div",{class:l(["max-w-md w-full space-y-8 backdrop-blur-lg bg-opacity-20 p-8 rounded-xl border border-opacity-20",[o(s)?"bg-gray-800 border-gray-600":"bg-white/70 border-gray-200"]])},[e("div",null,[e("div",I,[r[1]||(r[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)),r[2]||(r[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:l(["absolute inset-1 rounded-full flex items-center justify-center",o(s)?"bg-gray-800":"bg-white"])},[h(o(z),{class:l(["h-8 w-8",o(s)?"text-cyan-400":"text-cyan-600"])},null,8,["class"])],2)]),e("h2",{class:l(["mt-6 text-center text-3xl font-extrabold",o(s)?"text-white":"text-gray-900"])}," 登录 ",2)]),e("form",{class:"mt-8 space-y-6",onSubmit:k(f,["prevent"])},[r[5]||(r[5]=e("input",{type:"hidden",name:"remember",value:"true"},null,-1)),e("div",L,[e("div",null,[r[3]||(r[3]=e("label",{for:"password",class:"sr-only"},"密码",-1)),S(e("input",{id:"password",name:"password",type:"password",autocomplete:"current-password",required:"","onUpdate:modelValue":r[0]||(r[0]=g=>t.value=g),class:l(["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",o(s)?"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),[[A,t.value]])])]),e("div",null,[e("button",{type:"submit",class:l(["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",o(s)?"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",i.value?"opacity-75 cursor-not-allowed":""]),disabled:i.value},[r[4]||(r[4]=e("span",{class:"absolute left-0 inset-y-0 flex items-center pl-3"},null,-1)),V(" "+B(i.value?"登录中...":"登录"),1)],10,N)])],32)],2)],2))}}),E=_(T,[["__scopeId","data-v-2e50c3fa"]]);export{E as default};
@@ -1,4 +1,4 @@
var De=Object.defineProperty;var Pe=(d,e,t)=>e in d?De(d,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):d[e]=t;var v=(d,e,t)=>Pe(d,typeof e!="symbol"?e+"":e,t);import{c as _e,_ as Fe,u as Ze,s as Oe,r as D,o as Ue,a as Qe,w as He,b as Ne,d as I,e as p,f as $,n as w,g as f,i as Ve,t as j,h as Ge,j as We,k as Y,v as Xe,l as Q,m as Z,p as Ke,X as xe,T as Je,F as Ye,q as et,x as le,y as tt,z as L,A as nt}from"./index-BIfJi_O-.js";import{c as H,u as st,S as it,C as rt,a as ot,Q as lt,E as at}from"./_commonjsHelpers-CljsJuVy.js";import{B as ct}from"./box-C9xlYTT6.js";import{F as ke}from"./file-AHrKtGwY.js";import{H as ut,T as pt}from"./trash-wtxrZ93m.js";/** var De=Object.defineProperty;var Pe=(d,e,t)=>e in d?De(d,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):d[e]=t;var v=(d,e,t)=>Pe(d,typeof e!="symbol"?e+"":e,t);import{c as _e,_ as Fe,u as Ze,s as Oe,r as D,o as Ue,a as Qe,w as He,b as Ne,d as I,e as p,f as $,n as w,g as f,i as Ve,t as j,h as Ge,j as We,k as Y,v as Xe,l as Q,m as Z,p as Ke,X as xe,T as Je,F as Ye,q as et,x as le,y as tt,z as L,A as nt}from"./index-CiI2cp0V.js";import{c as H,u as st,S as it,C as rt,a as ot,Q as lt,E as at}from"./_commonjsHelpers-GlvjlJV-.js";import{B as ct}from"./box-BA8SMkDM.js";import{F as ke}from"./file-CWusGEpI.js";import{H as ut,T as pt}from"./trash-dDHutUhM.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as W,B as oe,r as z,o as ne,w as he,d as F,z as D,e as a,_ as se,u as ae,y as ge,i as pe,b as ye,p as ve,f as M,n as f,g as i,t as U,h as Q,l as E,k as N,j as J,v as X,x as K,C as be,F as Y,q as Z,m as V,X as xe,T as me,A as ee}from"./index-BIfJi_O-.js";import{g as we,u as _e,S as Ce,C as Ae,E as Me,a as Se,Q as Be}from"./_commonjsHelpers-CljsJuVy.js";import{F as te}from"./file-AHrKtGwY.js";import{T as Ie,H as Te}from"./trash-wtxrZ93m.js";/** import{c as W,B as oe,r as z,o as ne,w as he,d as F,z as D,e as a,_ as se,u as ae,y as ge,i as pe,b as ye,p as ve,f as M,n as f,g as i,t as U,h as Q,l as E,k as N,j as J,v as X,x as K,C as be,F as Y,q as Z,m as V,X as xe,T as me,A as ee}from"./index-CiI2cp0V.js";import{g as we,u as _e,S as Ce,C as Ae,E as Me,a as Se,Q as Be}from"./_commonjsHelpers-GlvjlJV-.js";import{F as te}from"./file-CWusGEpI.js";import{T as Ie,H as Te}from"./trash-dDHutUhM.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as k,B as F,D as R,r as _,E as K,o as V,F as X,G as x,H as Y}from"./index-BIfJi_O-.js";/** import{c as k,B as F,D as R,r as _,E as K,o as V,F as X,G as x,H as Y}from"./index-CiI2cp0V.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-BIfJi_O-.js";/** import{c as a}from"./index-CiI2cp0V.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as a}from"./index-BIfJi_O-.js";/** import{c as a}from"./index-CiI2cp0V.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as a}from"./index-BIfJi_O-.js";/** import{c as a}from"./index-CiI2cp0V.js";/**
* @license lucide-vue-next v0.445.0 - ISC * @license lucide-vue-next v0.445.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="keywords" content="{{keywords}}" /> <meta name="keywords" content="{{keywords}}" />
<meta name="generator" content="FileCodeBox2.2" /> <meta name="generator" content="FileCodeBox2.2" />
<title>{{title}}</title> <title>{{title}}</title>
<script type="module" crossorigin src="/assets/index-BIfJi_O-.js"></script> <script type="module" crossorigin src="/assets/index-CiI2cp0V.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bx419AMb.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bx419AMb.css">
</head> </head>
<body> <body>