V1.2.0 alpha 1
- 支持使用 api 提交物品信息 - 支持 JWT 鉴权
This commit is contained in:
7
JWT.py
Normal file
7
JWT.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from model import database
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
|
||||||
|
|
||||||
|
SECRET_KEY = asyncio.run(database.Database().get_setting('SECRET_KEY'))
|
||||||
270
auth.py
Normal file
270
auth.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import json
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from nicegui import ui, app
|
||||||
|
|
||||||
|
# 得到这样的字符串 / to get a string like this run:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
|
||||||
|
# 模拟存储的用户名和密码(实际应用中应该从数据库或其他安全存储中获取)
|
||||||
|
# Emulate the username and password stored in the database (in real applications, you should get them from a database or other secure storage)
|
||||||
|
fake_users_db = {
|
||||||
|
"johndoe": {
|
||||||
|
"username": "johndoe",
|
||||||
|
"full_name": "John Doe",
|
||||||
|
"email": "johndoe@example.com",
|
||||||
|
"hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
|
||||||
|
"disabled": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# FastAPI 鉴权模型
|
||||||
|
# FastAPI authentication model
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: str | None = None
|
||||||
|
full_name: str | None = None
|
||||||
|
disabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserInDB(User):
|
||||||
|
hashed_password: str
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
|
||||||
|
|
||||||
|
def verify_password(plain_password, hashed_password):
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password):
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(db, username: str):
|
||||||
|
if username in db:
|
||||||
|
user_dict = db[username]
|
||||||
|
return UserInDB(**user_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(fake_db, username: str, password: str):
|
||||||
|
user = get_user(fake_db, username)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return False
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
token_data = TokenData(username=username)
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise credentials_exception
|
||||||
|
user = get_user(fake_users_db, username=token_data.username)
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_active_user(
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
):
|
||||||
|
if current_user.disabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
# FastAPI 登录路由 / FastAPI login route
|
||||||
|
@app.post("/api/token")
|
||||||
|
async def login_for_access_token(
|
||||||
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
) -> Token:
|
||||||
|
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
|
# FastAPI 获取个人信息路由 / FastAPI get personal information route
|
||||||
|
@app.get("/api/profile")
|
||||||
|
async def profile(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
需要鉴权的路由示例
|
||||||
|
:param credentials: FastAPI 提供的用于解析HTTP Basic Auth头部的对象
|
||||||
|
:return: 成功时返回鉴权成功的消息,否则返回鉴权失败的HTTP错误
|
||||||
|
"""
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
# NiceGUI 获取个人信息路由 / NiceGUI get personal information route
|
||||||
|
@ui.page("/profile")
|
||||||
|
async def profile():
|
||||||
|
"""
|
||||||
|
需要鉴权的路由示例
|
||||||
|
:param credentials: FastAPI 提供的用于解析HTTP Basic Auth头部的对象
|
||||||
|
:return: 成功时返回鉴权成功的消息,否则返回鉴权失败的HTTP错误
|
||||||
|
"""
|
||||||
|
ui.add_head_html("""
|
||||||
|
<script>
|
||||||
|
async function getProfile() {
|
||||||
|
const accessToken = localStorage.getItem('access_token');
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return {'status': 'failed', 'detail': 'Access token not found'};
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = '/api/profile';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await response.json();
|
||||||
|
|
||||||
|
return {'status': 'success', 'profile': profile};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {'status': 'failed', 'detail': error.message};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
await ui.context.client.connected()
|
||||||
|
result = await ui.run_javascript("getProfile()")
|
||||||
|
if result['status'] == 'success':
|
||||||
|
ui.label(f"Profile: {json.dumps(result['profile'])}")
|
||||||
|
ui.button("Logout", on_click=lambda: ui.run_javascript("logout()"))
|
||||||
|
else:
|
||||||
|
ui.label("Failed to fetch profile. Please login first.")
|
||||||
|
ui.button("Login", on_click=lambda: ui.navigate.to("/login"))
|
||||||
|
|
||||||
|
|
||||||
|
# 登录界面路由 / Login page route
|
||||||
|
@ui.page("/login")
|
||||||
|
async def login_page():
|
||||||
|
"""
|
||||||
|
登录界面路由示例
|
||||||
|
:return: 一个登录页面
|
||||||
|
"""
|
||||||
|
ui.add_head_html("""
|
||||||
|
<script>
|
||||||
|
async function login(username, password) {
|
||||||
|
const url = '/api/token';
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
data.append('username', username);
|
||||||
|
data.append('password', password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// 处理登录成功后的数据,返回access_token
|
||||||
|
localStorage.setItem('access_token', result.access_token);
|
||||||
|
|
||||||
|
return {'status': 'success'};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {'status': 'failed', 'detail': error.message};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
|
||||||
|
async def try_login():
|
||||||
|
username = usrname.value
|
||||||
|
password = pwd.value
|
||||||
|
|
||||||
|
if username and password:
|
||||||
|
result = await ui.run_javascript(f"login('{username}', '{password}')")
|
||||||
|
if result['status'] == 'success':
|
||||||
|
ui.navigate.to("/profile")
|
||||||
|
else:
|
||||||
|
ui.notify("Login failed. Please try again.", type="negative")
|
||||||
|
usrname = ui.input("username")
|
||||||
|
pwd = ui.input("password", password=True)
|
||||||
|
ui.button("Login", on_click=try_login)
|
||||||
|
|
||||||
|
# 公开路由 / Public page route
|
||||||
|
@ui.page("/")
|
||||||
|
async def public_data():
|
||||||
|
"""
|
||||||
|
公开路由示例
|
||||||
|
:return: 一个公开的消息,无需鉴权
|
||||||
|
"""
|
||||||
|
ui.label("This is a public page.")
|
||||||
|
|
||||||
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
|
ui.run(uvicorn_logging_level='debug')
|
||||||
68
main.py
68
main.py
@@ -10,16 +10,13 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
from nicegui import app, ui
|
from nicegui import app, ui
|
||||||
from fastapi import Request
|
import model.database
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
import notfound
|
import notfound
|
||||||
import main_page
|
from routes.frontend import main_page
|
||||||
import found
|
from routes.frontend import found
|
||||||
import login
|
from routes.frontend import login
|
||||||
import admin
|
from routes.frontend import admin
|
||||||
|
from routes.backend import session
|
||||||
import model
|
import model
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
@@ -30,53 +27,7 @@ found.create()
|
|||||||
login.create()
|
login.create()
|
||||||
admin.create()
|
admin.create()
|
||||||
|
|
||||||
# 中间件配置文件
|
app.include_router(session.Router)
|
||||||
AUTH_CONFIG = {
|
|
||||||
"restricted_routes": {'/admin'},
|
|
||||||
"login_url": "/login",
|
|
||||||
"cookie_name": "session",
|
|
||||||
"session_expire": 3600 # 会话过期时间
|
|
||||||
}
|
|
||||||
|
|
||||||
def is_restricted_route(path: str) -> bool:
|
|
||||||
"""判断路径是否为需要认证的受限路由"""
|
|
||||||
# NiceGUI 路由不受限制
|
|
||||||
if path.startswith('/_nicegui'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 静态资源路径不受限制
|
|
||||||
if path.startswith('/static'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 主题路径不受限制
|
|
||||||
if path.startswith('/theme'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 后台路径始终受限
|
|
||||||
if path.startswith('/admin'):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 检查是否为受限的客户端页面路由
|
|
||||||
if path.startswith('/dash') or path.startswith('/user'):
|
|
||||||
return True
|
|
||||||
|
|
||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
|
||||||
try:
|
|
||||||
if not app.storage.user.get('authenticated', False):
|
|
||||||
path = request.url.path
|
|
||||||
|
|
||||||
if is_restricted_route(path):
|
|
||||||
logging.warning(f"未认证用户尝试访问: {path}")
|
|
||||||
return RedirectResponse(f'/login?redirect_to={path}')
|
|
||||||
|
|
||||||
return await call_next(request)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"服务器错误 Server error: {str(traceback.format_exc())}")
|
|
||||||
return JSONResponse(status_code=500, content={"detail": e})
|
|
||||||
|
|
||||||
# 添加中间件 Add middleware
|
|
||||||
app.add_middleware(AuthMiddleware)
|
|
||||||
|
|
||||||
# 添加静态文件目录
|
# 添加静态文件目录
|
||||||
try:
|
try:
|
||||||
@@ -86,16 +37,15 @@ except RuntimeError:
|
|||||||
|
|
||||||
# 启动函数 Startup function
|
# 启动函数 Startup function
|
||||||
def startup():
|
def startup():
|
||||||
asyncio.run(model.Database().init_db())
|
asyncio.run(model.database.Database().init_db())
|
||||||
ui.run(
|
ui.run(
|
||||||
host='0.0.0.0',
|
host='0.0.0.0',
|
||||||
favicon='🚀',
|
favicon='🚀',
|
||||||
port=8080,
|
port=8080,
|
||||||
title='Findreve',
|
title='Findreve',
|
||||||
native=False,
|
native=False,
|
||||||
storage_secret='findreve',
|
|
||||||
language='zh-CN',
|
language='zh-CN',
|
||||||
fastapi_docs=False)
|
fastapi_docs=True)
|
||||||
|
|
||||||
if __name__ in {"__main__", "__mp_main__"}:
|
if __name__ in {"__main__", "__mp_main__"}:
|
||||||
startup()
|
startup()
|
||||||
150
main_page.py
150
main_page.py
File diff suppressed because one or more lines are too long
1
model/__init__.py
Normal file
1
model/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import token
|
||||||
@@ -90,6 +90,15 @@ class Database:
|
|||||||
logging.info("插入初始密码信息")
|
logging.info("插入初始密码信息")
|
||||||
print(f"密码(请牢记,后续不再显示): {password}")
|
print(f"密码(请牢记,后续不再显示): {password}")
|
||||||
|
|
||||||
|
async with db.execute("SELECT name FROM fr_settings WHERE name = 'SECRET_KEY'") as cursor:
|
||||||
|
if not await cursor.fetchone():
|
||||||
|
secret_key = tool.generate_password(64)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
|
||||||
|
('string', 'SECRET_KEY', secret_key)
|
||||||
|
)
|
||||||
|
logging.info("插入初始密钥信息")
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logging.info("数据库初始化完成并提交更改")
|
logging.info("数据库初始化完成并提交更改")
|
||||||
|
|
||||||
@@ -118,7 +127,6 @@ class Database:
|
|||||||
"""更新对象信息
|
"""更新对象信息
|
||||||
|
|
||||||
:param id: 对象ID
|
:param id: 对象ID
|
||||||
:param key: 序列号
|
|
||||||
:param kwargs: 更新字段
|
:param kwargs: 更新字段
|
||||||
"""
|
"""
|
||||||
set_values = ", ".join([f"{k} = ?" for k in kwargs.keys()])
|
set_values = ", ".join([f"{k} = ?" for k in kwargs.keys()])
|
||||||
@@ -147,6 +155,15 @@ class Database:
|
|||||||
async with db.execute("SELECT * FROM fr_objects") as cursor:
|
async with db.execute("SELECT * FROM fr_objects") as cursor:
|
||||||
return await cursor.fetchall()
|
return await cursor.fetchall()
|
||||||
|
|
||||||
|
async def delete_object(self, id: int):
|
||||||
|
"""删除对象
|
||||||
|
|
||||||
|
:param id: 对象ID
|
||||||
|
"""
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute("DELETE FROM fr_objects WHERE id = ?", (id,))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
async def set_setting(self, name: str, value: str):
|
async def set_setting(self, name: str, value: str):
|
||||||
"""设置配置项
|
"""设置配置项
|
||||||
|
|
||||||
6
model/response.py
Normal file
6
model/response.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class DefaultResponse(BaseModel):
|
||||||
|
code: int = 0
|
||||||
|
data: dict | list | None = None
|
||||||
|
msg: str = ""
|
||||||
13
model/token.py
Normal file
13
model/token.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
# FastAPI 鉴权模型
|
||||||
|
# FastAPI authentication model
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
2
routes/backend/__init__.py
Normal file
2
routes/backend/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import admin
|
||||||
|
from . import session
|
||||||
74
routes/backend/admin.py
Normal file
74
routes/backend/admin.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from nicegui import app
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from jwt import InvalidTokenError
|
||||||
|
import jwt, JWT
|
||||||
|
from model import database
|
||||||
|
from model import token as Token
|
||||||
|
from model.response import DefaultResponse
|
||||||
|
|
||||||
|
async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]):
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Login required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, JWT.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise credentials_exception
|
||||||
|
if not username == await database.Database().get_setting('account'):
|
||||||
|
raise credentials_exception
|
||||||
|
token_data = Token.TokenData(username=username)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@app.get('/api/items')
|
||||||
|
async def get_items(
|
||||||
|
is_admin: Annotated[str, Depends(is_admin)],
|
||||||
|
id: int = None,
|
||||||
|
key: str = None):
|
||||||
|
items = await database.Database().get_object(id=id, key=key)
|
||||||
|
return DefaultResponse(data=items)
|
||||||
|
|
||||||
|
@app.post('/api/items')
|
||||||
|
async def add_items(
|
||||||
|
is_admin: Annotated[str, Depends(is_admin)],
|
||||||
|
key: str,
|
||||||
|
name: str,
|
||||||
|
icon: str,
|
||||||
|
phone: str):
|
||||||
|
try:
|
||||||
|
await database.Database().add_object(
|
||||||
|
key=key, name=name, icon=icon, phone=phone)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
else:
|
||||||
|
return DefaultResponse()
|
||||||
|
|
||||||
|
@app.patch('/api/items')
|
||||||
|
async def update_items(
|
||||||
|
is_admin: Annotated[str, Depends(is_admin)],
|
||||||
|
id: int,
|
||||||
|
**kwargs):
|
||||||
|
try:
|
||||||
|
await database.Database().update_object(
|
||||||
|
id=id, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
else:
|
||||||
|
return DefaultResponse()
|
||||||
|
|
||||||
|
@app.delete('/api/items')
|
||||||
|
async def delete_items(
|
||||||
|
is_admin: Annotated[str, Depends(is_admin)],
|
||||||
|
id: int):
|
||||||
|
try:
|
||||||
|
await database.Database().delete_object(id=id)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
else:
|
||||||
|
return DefaultResponse()
|
||||||
51
routes/backend/session.py
Normal file
51
routes/backend/session.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from nicegui import app
|
||||||
|
from typing import Annotated
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from fastapi import APIRouter
|
||||||
|
import jwt, JWT
|
||||||
|
|
||||||
|
from model.token import Token
|
||||||
|
from model import database
|
||||||
|
from tool import verify_password
|
||||||
|
|
||||||
|
Router = APIRouter()
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, JWT.SECRET_KEY, algorithm='HS256')
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
async def authenticate_user(username: str, password: str):
|
||||||
|
# 验证账号和密码
|
||||||
|
account = await database.Database().get_setting('account')
|
||||||
|
stored_password = await database.Database().get_setting('password')
|
||||||
|
|
||||||
|
if account != username or not verify_password(stored_password, password):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return {'is_authenticated': True}
|
||||||
|
|
||||||
|
# FastAPI 登录路由 / FastAPI login route
|
||||||
|
@app.post("/api/token")
|
||||||
|
async def login_for_access_token(
|
||||||
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
) -> Token:
|
||||||
|
user = await authenticate_user(form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token_expires = timedelta(hours=1)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": form_data.username}, expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
@@ -16,6 +16,7 @@ import qrcode
|
|||||||
import base64
|
import base64
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
import model.database
|
||||||
from tool import *
|
from tool import *
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ def create():
|
|||||||
object_key.set_value(generate_password())
|
object_key.set_value(generate_password())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await model.Database().add_object(key=object_key.value, name=object_name.value, icon=object_icon.value, phone=object_phone.value)
|
await model.database.Database().add_object(key=object_key.value, name=object_name.value, icon=object_icon.value, phone=object_phone.value)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
ui.notify(str(e), color='negative')
|
ui.notify(str(e), color='negative')
|
||||||
else:
|
else:
|
||||||
@@ -187,11 +188,11 @@ def create():
|
|||||||
try:
|
try:
|
||||||
# 获取选中物品
|
# 获取选中物品
|
||||||
object_id = object_table.selected[0]['id']
|
object_id = object_table.selected[0]['id']
|
||||||
await model.Database().update_object(id=object_id, status='lost')
|
await model.database.Database().update_object(id=object_id, status='lost')
|
||||||
await model.Database().update_object(id=object_id, lost_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
await model.database.Database().update_object(id=object_id, lost_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
# 如果设置了留言,则更新留言
|
# 如果设置了留言,则更新留言
|
||||||
if lostReason.value != "":
|
if lostReason.value != "":
|
||||||
await model.Database().update_object(id=object_id, context=lostReason.value)
|
await model.database.Database().update_object(id=object_id, context=lostReason.value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ui.notify(str(e), color='negative')
|
ui.notify(str(e), color='negative')
|
||||||
else:
|
else:
|
||||||
@@ -227,10 +228,10 @@ def create():
|
|||||||
async def findObject():
|
async def findObject():
|
||||||
try:
|
try:
|
||||||
object_id = object_table.selected[0]['id']
|
object_id = object_table.selected[0]['id']
|
||||||
await model.Database().update_object(id=object_id, status='ok')
|
await model.database.Database().update_object(id=object_id, status='ok')
|
||||||
await model.Database().update_object(id=object_id, context=None)
|
await model.database.Database().update_object(id=object_id, context=None)
|
||||||
await model.Database().update_object(id=object_id, find_ip=None)
|
await model.database.Database().update_object(id=object_id, find_ip=None)
|
||||||
await model.Database().update_object(id=object_id, lost_at=None)
|
await model.database.Database().update_object(id=object_id, lost_at=None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ui.notify(str(e), color='negative')
|
ui.notify(str(e), color='negative')
|
||||||
else:
|
else:
|
||||||
@@ -261,7 +262,7 @@ def create():
|
|||||||
async def fetch_and_process_objects():
|
async def fetch_and_process_objects():
|
||||||
# 获取所有物品
|
# 获取所有物品
|
||||||
objects = [dict(zip(['id', 'key', 'name', 'icon', 'status', 'phone', 'context',
|
objects = [dict(zip(['id', 'key', 'name', 'icon', 'status', 'phone', 'context',
|
||||||
'find_ip', 'create_at', 'lost_at'], obj)) for obj in await model.Database().get_object()]
|
'find_ip', 'create_at', 'lost_at'], obj)) for obj in await model.database.Database().get_object()]
|
||||||
status_map = {'ok': '正常', 'lost': '丢失'}
|
status_map = {'ok': '正常', 'lost': '丢失'}
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
obj['status'] = status_map.get(obj['status'], obj['status'])
|
obj['status'] = status_map.get(obj['status'], obj['status'])
|
||||||
@@ -11,7 +11,7 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
|||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from model import Database
|
from model.database import Database
|
||||||
from tool import format_phone
|
from tool import format_phone
|
||||||
|
|
||||||
def create() -> None:
|
def create() -> None:
|
||||||
29
routes/frontend/framework.py
Normal file
29
routes/frontend/framework.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from nicegui import ui
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def frame(request: Request):
|
||||||
|
with ui.header() \
|
||||||
|
.classes('items-center duration-300 py-2 px-5 no-wrap') \
|
||||||
|
.style('box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1)'):
|
||||||
|
with ui.tabs(value='main_page') as tabs:
|
||||||
|
ui.button(icon='menu', on_click=lambda: left_drawer.toggle()).props('flat color=white round')
|
||||||
|
ui.button(text="Findreve 仪表盘").classes('text-lg').props('flat color=white no-caps')
|
||||||
|
|
||||||
|
with ui.left_drawer() as left_drawer:
|
||||||
|
with ui.column().classes('w-full'):
|
||||||
|
ui.image('/static/Findreve.png').classes('w-1/2 mx-auto')
|
||||||
|
ui.label('Findreve').classes('text-2xl text-bold')
|
||||||
|
ui.label("免费版,无需授权").classes('text-red-600 -mt-3')
|
||||||
|
|
||||||
|
ui.button('首页 & 信息', icon='fingerprint', on_click=lambda: ui.navigate.to('/admin/home')) \
|
||||||
|
.classes('w-full').props('flat no-caps')
|
||||||
|
ui.button('物品 & 库存', icon='settings', on_click=lambda: ui.navigate.to('/admin/item')) \
|
||||||
|
.classes('w-full').props('flat no-caps')
|
||||||
|
ui.button('产品 & 授权', icon='settings', on_click=lambda: ui.navigate.to('/admin/auth')) \
|
||||||
|
.classes('w-full').props('flat no-caps')
|
||||||
|
ui.button('关于 & 反馈', icon='settings', on_click=lambda: ui.navigate.to('/admin/about')) \
|
||||||
|
.classes('w-full').props('flat no-caps')
|
||||||
|
|
||||||
|
yield
|
||||||
@@ -11,17 +11,46 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
|||||||
|
|
||||||
from nicegui import ui, app
|
from nicegui import ui, app
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import model
|
|
||||||
import tool
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
def create() -> Optional[RedirectResponse]:
|
def create() -> Optional[RedirectResponse]:
|
||||||
@ui.page('/login')
|
@ui.page('/login')
|
||||||
async def session(request: Request, redirect_to: str = "/"):
|
async def session(request: Request, redirect_to: str = "/"):
|
||||||
# 检测是否已登录
|
ui.add_head_html("""
|
||||||
if app.storage.user.get('authenticated', False):
|
<script>
|
||||||
return ui.navigate.to(redirect_to)
|
async function login(username, password) {
|
||||||
|
const url = '/api/token';
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
data.append('username', username);
|
||||||
|
data.append('password', password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// 处理登录成功后的数据,返回access_token
|
||||||
|
localStorage.setItem('access_token', result.access_token);
|
||||||
|
|
||||||
|
return {'status': 'success'};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {'status': 'failed', 'detail': error.message};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
|
||||||
ui.page_title('登录 Findreve')
|
ui.page_title('登录 Findreve')
|
||||||
async def try_login() -> None:
|
async def try_login() -> None:
|
||||||
@@ -34,18 +63,14 @@ def create() -> Optional[RedirectResponse]:
|
|||||||
ui.notify('账号或密码不能为空', color='negative')
|
ui.notify('账号或密码不能为空', color='negative')
|
||||||
return
|
return
|
||||||
|
|
||||||
# 验证账号和密码
|
try:
|
||||||
account = await model.Database().get_setting('account')
|
result = await ui.run_javascript(f"login('{username}', '{password}')")
|
||||||
stored_password = await model.Database().get_setting('password')
|
if result['status'] == 'success':
|
||||||
|
|
||||||
if account != username.value or not tool.verify_password(stored_password, password.value, debug=True):
|
|
||||||
ui.notify('账号或密码错误', color='negative')
|
|
||||||
return
|
|
||||||
|
|
||||||
# 存储用户信息
|
|
||||||
app.storage.user.update({'authenticated': True})
|
|
||||||
# 跳转到用户上一页
|
|
||||||
ui.navigate.to(redirect_to)
|
ui.navigate.to(redirect_to)
|
||||||
|
else:
|
||||||
|
ui.notify("账号或密码错误", type="negative")
|
||||||
|
except Exception as e:
|
||||||
|
ui.notify(f"登录失败: {str(e)}", type="negative")
|
||||||
|
|
||||||
|
|
||||||
with ui.header() \
|
with ui.header() \
|
||||||
@@ -59,12 +84,12 @@ def create() -> Optional[RedirectResponse]:
|
|||||||
with ui.card().classes('absolute-center round-lg').style('width: 70%; max-width: 500px'):
|
with ui.card().classes('absolute-center round-lg').style('width: 70%; max-width: 500px'):
|
||||||
# 登录标签
|
# 登录标签
|
||||||
ui.button(icon='lock').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
|
ui.button(icon='lock').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
|
||||||
ui.label('登录 HeyPress').classes('text-h5 w-full text-center')
|
ui.label('登录 Findreve').classes('text-h5 w-full text-center')
|
||||||
# 用户名/密码框
|
# 用户名/密码框
|
||||||
username = ui.input('账号').on('keydown.enter', try_login) \
|
username = ui.input('账号').on('keydown.enter', try_login) \
|
||||||
.classes('block w-full text-gray-900').props('rounded outlined')
|
.classes('block w-full text-gray-900').props('filled')
|
||||||
password = ui.input('密码', password=True, password_toggle_button=True) \
|
password = ui.input('密码', password=True, password_toggle_button=True) \
|
||||||
.on('keydown.enter', try_login).classes('block w-full text-gray-900').props('rounded outlined')
|
.on('keydown.enter', try_login).classes('block w-full text-gray-900').props('filled')
|
||||||
|
|
||||||
# 按钮布局
|
# 按钮布局
|
||||||
ui.button('登录', on_click=lambda: login()).classes('items-center w-full').props('rounded')
|
ui.button('登录', on_click=lambda: login()).classes('items-center w-full').props('rounded')
|
||||||
148
routes/frontend/main_page.py
Normal file
148
routes/frontend/main_page.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'''
|
||||||
|
Author: 于小丘 海枫
|
||||||
|
Date: 2024-10-02 15:23:34
|
||||||
|
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||||
|
LastEditTime: 2024-11-29 20:04:24
|
||||||
|
FilePath: /Findreve/main_page.py
|
||||||
|
Description: Findreve 个人主页 main_page
|
||||||
|
|
||||||
|
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||||
|
'''
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
from fastapi import Request
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import model
|
||||||
|
|
||||||
|
|
||||||
|
def create() -> None:
|
||||||
|
@ui.page('/')
|
||||||
|
async def main_page(request: Request) -> None:
|
||||||
|
|
||||||
|
dark_mode = ui.dark_mode(value=True)
|
||||||
|
|
||||||
|
with ui.row(align_items='center').classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl p-24'):
|
||||||
|
with ui.column(align_items='center').classes('px-2 max-md:hidden'):
|
||||||
|
ui.chip('🐍 Python 是最好的语言').classes('text-xs -mt-1 -right-3').props('floating outline')
|
||||||
|
ui.chip('🎹 精通 FL Studio Mobile').classes('text-xs -mt-1').props('floating outline')
|
||||||
|
ui.chip('🎨 熟悉 Ps/Pr/Ae/Au/Ai').classes('text-xs -mt-1').props('floating outline')
|
||||||
|
ui.chip('🏎 热爱竞速(如地平线5)').classes('text-xs -mt-1 -right-3').props('floating outline')
|
||||||
|
with ui.avatar().classes('w-32 h-32 transition-transform duration-300 hover:scale-110 cursor-pointer'):
|
||||||
|
# 下面的这个是Base64格式,你需要改成你自己的头像,支持链接,也可以用Base64本地化
|
||||||
|
ui.image('/static/heyfun.jpg').classes('w-32 h-32')
|
||||||
|
with ui.column().classes('px-2 max-md:hidden'):
|
||||||
|
ui.chip('喜欢去广州图书馆看书 📕').classes('text-xs -mt-1 -left-3').props('floating outline')
|
||||||
|
ui.chip('致力做安卓苹果开发者 📱').classes('text-xs -mt-1').props('floating outline')
|
||||||
|
ui.chip('正在自研全链个人生态 🔧').classes('text-xs -mt-1').props('floating outline')
|
||||||
|
ui.chip('致力与开源社区同发展 🤝').classes('text-xs -mt-1 -left-3').props('floating outline')
|
||||||
|
|
||||||
|
ui.label('关于本站').classes('w-full text-4xl text-bold text-center py-6 subpixel-antialiased')
|
||||||
|
|
||||||
|
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl py-4'):
|
||||||
|
with ui.card().classes('w-full sm:w-1/5 lg:w-1/7 flex-grow p-8 bg-gradient-to-br from-indigo-700 to-blue-500'):
|
||||||
|
ui.label('你好,很高兴认识你👋').classes('text-md text-white')
|
||||||
|
with ui.row(align_items='center'):
|
||||||
|
ui.label('我叫').classes('text-4xl text-bold text-white -mt-1 subpixel-antialiased')
|
||||||
|
ui.label('于小丘').classes('text-4xl text-bold text-white -mt-1 subpixel-antialiased').tooltip('英文名叫Yuerchu,也可以叫我海枫')
|
||||||
|
ui.label('是一名 开发者、音乐人').classes('text-md text-white -mt-1')
|
||||||
|
with ui.card().classes('w-full sm:w-1/2 lg:w-1/4 flex-grow flex flex-col justify-center'):
|
||||||
|
ui.code('void main() {\n printf("为了尚未完成的未来");\n}', language='c').classes('text-3xl max-[768px]:text-xl text-bold text-white flex-grow w-full h-full')
|
||||||
|
|
||||||
|
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl -mt-3'):
|
||||||
|
with ui.card().classes('w-full sm:w-1/2 lg:w-1/4 flex-grow p-4'):
|
||||||
|
ui.label('技能').classes('text-md text-gray-500')
|
||||||
|
ui.label('开启创造力').classes('text-4xl text-bold -mt-1 right-4')
|
||||||
|
|
||||||
|
with ui.row().classes('items-center'):
|
||||||
|
ui.chip('Python', color='amber-400').classes('p-4').props('floating').tooltip('Python是世界上最好的语言')
|
||||||
|
ui.chip('Kotlin', color='violet-400').classes('p-4').props('floating').tooltip('Kotlin给安卓开发APP')
|
||||||
|
ui.chip('Golang', color='sky-400').classes('p-4').props('floating').tooltip('Golang写后端')
|
||||||
|
ui.chip('Lua', color='blue-900').classes('p-4').props('floating').tooltip('用aLua给安卓开发,给罗技鼠标写鼠标宏')
|
||||||
|
ui.chip('c', color='red-400').classes('p-4').props('floating').tooltip('C写嵌入式开发')
|
||||||
|
ui.chip('FL Studio', color='orange-600').classes('p-4').props('floating').tooltip('FL Studio是世界上最好的宿主')
|
||||||
|
ui.chip('Photoshop', color='blue-950').classes('p-4').props('floating').tooltip('修图/抠图/画画一站通')
|
||||||
|
ui.chip('Premiere', color='indigo-900').classes('p-4').props('floating').tooltip('剪视频比较顺手,但是一开风扇狂转')
|
||||||
|
ui.chip('After Effects', color='indigo-950').classes('p-4').props('floating').tooltip('制作特效,电脑太烂了做不了太花的')
|
||||||
|
ui.chip('Audition', color='purple-900').classes('p-4').props('floating').tooltip('写歌做母带挺好用的')
|
||||||
|
ui.chip('Illustrator', color='amber-800').classes('p-4').props('floating').tooltip('自制字体和画动态SVG')
|
||||||
|
ui.chip('HTML', color='red-900').classes('p-4').props('floating').tooltip('前端入门三件套,不学这玩意其他学了没用')
|
||||||
|
ui.chip('CSS3', color='cyan-900').classes('p-4').props('floating').tooltip('. window{ show: none; }')
|
||||||
|
ui.chip('JavaScript', color='lime-900').classes('p-4').props('floating').tooltip('还在努力学习中,只会一些简单的')
|
||||||
|
ui.chip('git', color='amber-700').classes('p-4').props('floating').tooltip('版本管理是真好用')
|
||||||
|
ui.chip('Docker', color='sky-600').classes('p-4').props('floating').tooltip('容器化部署')
|
||||||
|
ui.chip('chatGPT', color='emerald-600').classes('p-4').props('floating').tooltip('文本助驾,写代码/写文章/写论文')
|
||||||
|
ui.chip('SAI2', color='gray-950').classes('p-4').props('floating').tooltip('入门绘画')
|
||||||
|
ui.chip('ips Draw', color='gray-900').classes('p-4').props('floating').tooltip('自认为是iOS端最佳绘画软件')
|
||||||
|
ui.chip('AutoCAD', color='gray-950').classes('p-4').props('floating').tooltip('画图/绘制电路图')
|
||||||
|
ui.chip('SolidWorks', color='gray-900').classes('p-4').props('floating').tooltip('画图/绘制3D模型')
|
||||||
|
ui.chip('EasyEDA', color='gray-950').classes('p-4').props('floating').tooltip('画图/绘制电路图')
|
||||||
|
ui.chip('KiCad', color='gray-900').classes('p-4').props('floating').tooltip('画图/绘制电路图')
|
||||||
|
ui.chip('Altium Designer', color='gray-950').classes('p-4').props('floating').tooltip('画图/绘制电路图')
|
||||||
|
ui.label('...').classes('text-md text-gray-500')
|
||||||
|
with ui.card().classes('w-full sm:w-1/3 lg:w-1/6 flex-grow flex flex-col justify-center'):
|
||||||
|
ui.label('生涯').classes('text-md text-gray-500')
|
||||||
|
ui.label('无限进步').classes('text-4xl text-bold -mt-1 right-4')
|
||||||
|
|
||||||
|
with ui.timeline(side='right', layout='comfortable'):
|
||||||
|
ui.timeline_entry('那天,我买了第一台服务器,并搭建了我第一个Wordpress站点',
|
||||||
|
title='梦开始的地方',
|
||||||
|
subtitle='2022年1月21日')
|
||||||
|
ui.timeline_entry('准备从Cloudreve项目脱离,自建网盘系统DiskNext',
|
||||||
|
title='自建生态计划开始',
|
||||||
|
subtitle='2024年3月1日')
|
||||||
|
ui.timeline_entry('目前正在开发HeyAuth、Findreve、DiskNext',
|
||||||
|
title='项目框架仍在研发中',
|
||||||
|
subtitle='现在',
|
||||||
|
icon='rocket')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ui.label('我的作品').classes('w-full text-center text-2xl text-bold p-4')
|
||||||
|
|
||||||
|
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl'):
|
||||||
|
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||||
|
with ui.row().classes('items-center w-full -mt-2'):
|
||||||
|
ui.label('DiskNext').classes('text-lg text-bold')
|
||||||
|
ui.chip('B端程序').classes('text-xs').props('floating')
|
||||||
|
ui.space()
|
||||||
|
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://pan.yxqi.cn'))).props('flat fab-mini')
|
||||||
|
ui.label('一个基于NiceGUI的网盘系统,性能与Golang媲美').classes('text-sm -mt-3')
|
||||||
|
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||||
|
with ui.row().classes('items-center w-full -mt-2'):
|
||||||
|
ui.label('Findreve').classes('text-lg text-bold')
|
||||||
|
ui.chip('C端程序').classes('text-xs').props('floating')
|
||||||
|
ui.space()
|
||||||
|
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://i.yxqi.cn'))).props('flat fab-mini')
|
||||||
|
ui.label('一个基于NiceGUI的个人主页配合物品丢失找回系统').classes('text-sm -mt-3')
|
||||||
|
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||||
|
with ui.row().classes('items-center w-full -mt-2'):
|
||||||
|
ui.label('HeyAuth').classes('text-lg text-bold')
|
||||||
|
ui.chip('B端程序').classes('text-xs').props('floating')
|
||||||
|
ui.space()
|
||||||
|
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://auth.yxqi.cn'))).props('flat fab-mini')
|
||||||
|
ui.label('一个基于NiceGUI的B+C端多应用授权系统').classes('text-sm -mt-3')
|
||||||
|
|
||||||
|
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl'):
|
||||||
|
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||||
|
with ui.row().classes('items-center w-full -mt-2'):
|
||||||
|
ui.label('与枫同奔 Run With Fun').classes('text-lg text-bold')
|
||||||
|
ui.chip('词曲').classes('text-xs').props('floating')
|
||||||
|
ui.space()
|
||||||
|
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=2148944359'))).props('flat fab-mini')
|
||||||
|
ui.label('我愿如流星赶月那样飞奔').classes('text-sm -mt-3')
|
||||||
|
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||||
|
with ui.row().classes('items-center w-full -mt-2'):
|
||||||
|
ui.label('HeyFun\'s Story').classes('text-lg text-bold')
|
||||||
|
ui.chip('自设印象曲').classes('text-xs').props('floating')
|
||||||
|
ui.space()
|
||||||
|
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=1889436124'))).props('flat fab-mini')
|
||||||
|
ui.label('飞奔在星辰大海之间的少年').classes('text-sm -mt-3')
|
||||||
|
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||||
|
with ui.row().classes('items-center w-full -mt-2'):
|
||||||
|
ui.label('2020Fall').classes('text-lg text-bold')
|
||||||
|
ui.chip('年度纯音乐').classes('text-xs').props('floating')
|
||||||
|
ui.space()
|
||||||
|
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=1863630345'))).props('flat fab-mini')
|
||||||
|
ui.label('耗时6个月完成的年度纯音乐').classes('text-sm -mt-3')
|
||||||
BIN
static/heyfun.jpg
Normal file
BIN
static/heyfun.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
18
tool.py
18
tool.py
@@ -9,8 +9,6 @@ Description: Findreve 小工具 tool
|
|||||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
import string
|
|
||||||
import random
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import binascii
|
import binascii
|
||||||
import logging
|
import logging
|
||||||
@@ -44,11 +42,17 @@ def format_phone(phone: str, groups: list = None, separator: str = " ", private:
|
|||||||
return separator.join(result)
|
return separator.join(result)
|
||||||
|
|
||||||
def generate_password(length: int = 8) -> str:
|
def generate_password(length: int = 8) -> str:
|
||||||
# 定义密码字符集,大小写字母和数字
|
"""
|
||||||
characters = string.ascii_letters + string.digits
|
生成指定长度的随机密码。
|
||||||
# 随机选择length个字符,生成密码
|
|
||||||
password = ''.join(random.choice(characters) for i in range(length))
|
:param length: 密码长度
|
||||||
return password
|
:type length: int
|
||||||
|
:return: 随机密码
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
return secrets.token_hex(length)
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user