V1.3.1
This commit is contained in:
56
.github/copilot-instructions.md
vendored
Normal file
56
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Findreve 项目指南 - GitHub Copilot 指令
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
Findreve 是一个专为个人用户设计的物品管理系统,旨在提供安全、灵活的物品管理服务。
|
||||||
|
基于 Python 技术栈开发,使用 NiceGUI 构建用户界面(但在逐步迁移到 FastAPI + Vuetify),
|
||||||
|
FastAPI 提供 API 服务,AioSqlite 用于异步数据库操作。
|
||||||
|
|
||||||
|
## 项目规划
|
||||||
|
[x] JWT 账号管理
|
||||||
|
[x] 管理员端物品的增删改查
|
||||||
|
[x] 物品丢失页面
|
||||||
|
[x] 物品丢失时记录访问者IP
|
||||||
|
[ ] 迁移到 Vuetify 等流行前端组件
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
- 使用 Python 3.13.2 编写所有代码
|
||||||
|
- 遵循 PEP 8 代码风格规范
|
||||||
|
- 使用类型提示增强代码可读性
|
||||||
|
- 所有函数和类都应有reST风格的文档字符串(docstring)
|
||||||
|
- 项目的日志模块使用英语输出
|
||||||
|
- 使用异步编程模式处理并发
|
||||||
|
- 尽可能写出弹性可扩展、可维护的代码
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
- `/model`: 模型组件
|
||||||
|
- `database`: AioSQLite数据库组件
|
||||||
|
- `/routes`: 路由组件
|
||||||
|
- `backend`: 直接由 FastAPI 控制的路由(后端路由)
|
||||||
|
- `frontend`: 由 NiceGUI 渲染的路由(前端路由)
|
||||||
|
- `/static`: 静态文件(会自动挂载到网站的 `/static` 目录下)
|
||||||
|
- `main.py`: 启动项目
|
||||||
|
|
||||||
|
## 回复用户规则
|
||||||
|
- 当用户提出了产品的问题或者解决问题的思路时,应当在适时且随机的时候回答前肯定用户的想法
|
||||||
|
- 如 `你的理解非常到位,抓住了问题的核心`、`这个想法非常不错` 等等
|
||||||
|
- 每次鼓励尽可能用不同的词语和语法,但也不要次次都鼓励
|
||||||
|
- 除非用户明确说明需要你来实现相关功能,否则只给出思路,不要给出实现代码甚至直接为用户编辑
|
||||||
|
|
||||||
|
## 命名约定
|
||||||
|
- 类名: PascalCase
|
||||||
|
- 函数和变量: snake_case
|
||||||
|
- 常量: UPPER_SNAKE_CASE
|
||||||
|
- 文件名: snake_case.py
|
||||||
|
- 模块名: snake_case
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
- 使用 Pydantic 模型进行数据验证
|
||||||
|
- 利用 FastAPI 依赖注入系统管理服务依赖
|
||||||
|
- 使用 AioSQLite 进行数据库操作
|
||||||
|
- 结构化错误处理和日志记录
|
||||||
|
|
||||||
|
## API文档
|
||||||
|
- 使用 FastAPI 的自动文档功能
|
||||||
|
- 为所有端点提供详细说明和示例
|
||||||
|
- 实现 OpenAPI 规范
|
||||||
|
- 文档应随代码更改自动更新
|
||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,20 @@
|
|||||||
# Findreve 更新日志
|
# Findreve 更新日志
|
||||||
|
|
||||||
|
## V1.3.1 2025-04-18
|
||||||
|
- 新增 Github Copilot 指令
|
||||||
|
|
||||||
|
- 优化 管理员鉴权的代码逻辑
|
||||||
|
- 优化 Framework 左侧的 Logo 尺寸显示
|
||||||
|
- 优化 管理员面板跳转的重定向显示
|
||||||
|
- 优化 CSS 的保存位置
|
||||||
|
- 优化 代码结构
|
||||||
|
|
||||||
|
- 修复 登录后访问登录页面不跳转
|
||||||
|
|
||||||
|
- 更新 requirements.txt
|
||||||
|
|
||||||
|
- 删除 一个历史遗留组件
|
||||||
|
|
||||||
## V1.3.0 2025-04-18
|
## V1.3.0 2025-04-18
|
||||||
- 新增 全站前后端分离
|
- 新增 全站前后端分离
|
||||||
- 新增 物品页懒加载
|
- 新增 物品页懒加载
|
||||||
|
|||||||
4
JWT.py
4
JWT.py
@@ -2,6 +2,8 @@ from fastapi.security import OAuth2PasswordBearer
|
|||||||
from model import database
|
from model import database
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
|
oauth2_scheme = OAuth2PasswordBearer(
|
||||||
|
tokenUrl="/api/token"
|
||||||
|
)
|
||||||
|
|
||||||
SECRET_KEY = asyncio.run(database.Database().get_setting('SECRET_KEY'))
|
SECRET_KEY = asyncio.run(database.Database().get_setting('SECRET_KEY'))
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.yxqi.cn">Homepage</a> •
|
<a href="https://www.yxqi.cn">Homepage</a> •
|
||||||
<a href="https://find.yxqi.cn">Demo</a> •
|
<a href="https://find.yxqi.cn">Demo</a> •
|
||||||
<a href="https://findreve.yxqi.cn">Documents</a> •
|
<a href="./CHANGELOG.md">ChangeLog</a> •
|
||||||
<a href="https://github.com/Findreve/Findreve/releases">Download</a> •
|
<a href="https://github.com/Findreve/Findreve/releases">Download</a> •
|
||||||
<a href="#License">License</a>
|
<a href="#License">License</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
270
auth.py
270
auth.py
@@ -1,270 +0,0 @@
|
|||||||
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')
|
|
||||||
@@ -15,5 +15,5 @@ from fastapi.responses import HTMLResponse
|
|||||||
|
|
||||||
def create() -> None:
|
def create() -> None:
|
||||||
@app.get('/404')
|
@app.get('/404')
|
||||||
async def not_found_page(request: Request) -> None:
|
async def not_found_page(request: Request) -> HTMLResponse:
|
||||||
return HTMLResponse(status_code=404)
|
return HTMLResponse(status_code=404)
|
||||||
|
|||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@@ -8,8 +8,6 @@ from model import database
|
|||||||
from model.response import DefaultResponse
|
from model.response import DefaultResponse
|
||||||
from model.items import Item
|
from model.items import Item
|
||||||
|
|
||||||
Router = APIRouter(prefix='/api/admin', tags=['admin'])
|
|
||||||
|
|
||||||
async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]):
|
async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]):
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -27,17 +25,22 @@ async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]):
|
|||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
Router = APIRouter(
|
||||||
|
prefix='/api/admin',
|
||||||
|
tags=['admin'],
|
||||||
|
dependencies=[Depends(is_admin)]
|
||||||
|
)
|
||||||
|
|
||||||
@Router.get('/')
|
@Router.get('/')
|
||||||
async def is_admin(
|
async def verity_admin(
|
||||||
is_admin: Annotated[str, Depends(is_admin)]
|
is_admin: Annotated[str, Depends(is_admin)]
|
||||||
):
|
):
|
||||||
return is_admin
|
return is_admin
|
||||||
|
|
||||||
@Router.get('/items')
|
@Router.get('/items')
|
||||||
async def get_items(
|
async def get_items(
|
||||||
is_admin: Annotated[str, Depends(is_admin)],
|
id: Optional[int] = None,
|
||||||
id: int = None,
|
key: Optional[str] = None):
|
||||||
key: str = None):
|
|
||||||
results = await database.Database().get_object(id=id, key=key)
|
results = await database.Database().get_object(id=id, key=key)
|
||||||
|
|
||||||
if results is not None:
|
if results is not None:
|
||||||
@@ -65,7 +68,6 @@ async def get_items(
|
|||||||
|
|
||||||
@Router.post('/items')
|
@Router.post('/items')
|
||||||
async def add_items(
|
async def add_items(
|
||||||
is_admin: Annotated[str, Depends(is_admin)],
|
|
||||||
key: str,
|
key: str,
|
||||||
name: str,
|
name: str,
|
||||||
icon: str,
|
icon: str,
|
||||||
@@ -75,13 +77,12 @@ async def add_items(
|
|||||||
|
|
||||||
@Router.patch('/items')
|
@Router.patch('/items')
|
||||||
async def update_items(
|
async def update_items(
|
||||||
is_admin: Annotated[str, Depends(is_admin)],
|
|
||||||
id: int,
|
id: int,
|
||||||
key: str = None,
|
key: Optional[str] = None,
|
||||||
name: str = None,
|
name: Optional[str] = None,
|
||||||
icon: str = None,
|
icon: Optional[str] = None,
|
||||||
status: str = None,
|
status: Optional[str] = None,
|
||||||
phone: int = None,
|
phone: Optional[int] = None,
|
||||||
lost_description: Optional[str] = None,
|
lost_description: Optional[str] = None,
|
||||||
find_ip: Optional[str] = None,
|
find_ip: Optional[str] = None,
|
||||||
lost_time: Optional[str] = None):
|
lost_time: Optional[str] = None):
|
||||||
@@ -99,7 +100,6 @@ async def update_items(
|
|||||||
|
|
||||||
@Router.delete('/items')
|
@Router.delete('/items')
|
||||||
async def delete_items(
|
async def delete_items(
|
||||||
is_admin: Annotated[str, Depends(is_admin)],
|
|
||||||
id: int):
|
id: int):
|
||||||
try:
|
try:
|
||||||
await database.Database().delete_object(id=id)
|
await database.Database().delete_object(id=id)
|
||||||
|
|||||||
@@ -10,49 +10,20 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
from fastapi import Request
|
||||||
from tool import *
|
from tool import *
|
||||||
from ..framework import frame
|
from ..framework import frame
|
||||||
|
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
@ui.page('/admin/about')
|
@ui.page('/admin/about')
|
||||||
async def admin_about():
|
async def admin_about(request: Request):
|
||||||
|
|
||||||
dark_mode = ui.dark_mode(value=True)
|
|
||||||
|
|
||||||
ui.add_head_html(
|
|
||||||
code="""
|
|
||||||
<style>
|
|
||||||
a:link:not(.browser-window *),
|
|
||||||
a:visited:not(.browser-window *) {
|
|
||||||
color: inherit !important;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover:not(.browser-window *),
|
|
||||||
a:active:not(.browser-window *) {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bold-links a:link {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-links a:link:not(.auto-link)::after {
|
|
||||||
content: "north_east";
|
|
||||||
font-family: "Material Icons";
|
|
||||||
font-weight: 100;
|
|
||||||
vertical-align: -10%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
ui.add_head_html("""
|
ui.add_head_html("""
|
||||||
|
<style type="text/css" src="/static/css/about.css"></style>
|
||||||
<script type="text/javascript" src="/static/js/main.js"></script>
|
<script type="text/javascript" src="/static/js/main.js"></script>
|
||||||
""")
|
""")
|
||||||
|
|
||||||
async with frame():
|
async with frame(request=request):
|
||||||
|
|
||||||
# 关于 Findreve
|
# 关于 Findreve
|
||||||
with ui.tab_panel('about'):
|
with ui.tab_panel('about'):
|
||||||
|
|||||||
@@ -11,17 +11,15 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
|||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
from tool import *
|
from tool import *
|
||||||
|
from fastapi import Request
|
||||||
from ..framework import frame
|
from ..framework import frame
|
||||||
|
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
@ui.page('/admin/auth')
|
@ui.page('/admin/auth')
|
||||||
async def admin_auth():
|
async def admin_auth(request: Request):
|
||||||
|
|
||||||
dark_mode = ui.dark_mode(value=True)
|
|
||||||
|
|
||||||
# Findreve 授权
|
# Findreve 授权
|
||||||
async with frame():
|
async with frame(request=request):
|
||||||
|
|
||||||
ui.label('Findreve 授权').classes('text-2xl text-bold')
|
ui.label('Findreve 授权').classes('text-2xl text-bold')
|
||||||
|
|
||||||
|
|||||||
@@ -9,22 +9,21 @@ Description: Findreve 后台管理 admin
|
|||||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui, app
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from tool import *
|
from tool import *
|
||||||
from ..framework import frame
|
from ..framework import frame
|
||||||
|
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
@ui.page('/admin')
|
@app.get('/admin')
|
||||||
async def jump():
|
async def jump():
|
||||||
ui.navigate.to('/admin/home')
|
return RedirectResponse(url='/admin/home')
|
||||||
|
|
||||||
@ui.page('/admin/home')
|
@ui.page('/admin/home')
|
||||||
async def admin_home():
|
async def admin_home(request: Request):
|
||||||
|
async with frame(request=request):
|
||||||
dark_mode = ui.dark_mode(value=True)
|
|
||||||
|
|
||||||
async with frame():
|
|
||||||
with ui.tab_panel('main_page'):
|
with ui.tab_panel('main_page'):
|
||||||
ui.label('首页配置').classes('text-2xl text-bold')
|
ui.label('首页配置').classes('text-2xl text-bold')
|
||||||
ui.label('暂不支持,请直接修改main_page.py').classes('text-md text-gray-600').classes('w-full')
|
ui.label('暂不支持,请直接修改main_page.py').classes('text-md text-gray-600').classes('w-full')
|
||||||
@@ -94,7 +94,7 @@ def create() -> None:
|
|||||||
|
|
||||||
await ui.context.client.connected()
|
await ui.context.client.connected()
|
||||||
|
|
||||||
async with frame(page='found'):
|
async with frame(page='found', request=request):
|
||||||
if key == "" or key == None:
|
if key == "" or key == None:
|
||||||
ui.navigate.to('/404')
|
ui.navigate.to('/404')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from fastapi import Request
|
||||||
from typing import Optional, Literal
|
from typing import Optional, Literal
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def frame(
|
async def frame(
|
||||||
page: Literal['admin', 'session', 'found'] = 'admin'
|
request: Request = None,
|
||||||
|
page: Literal['admin', 'session', 'found'] = 'admin',
|
||||||
|
redirect_to: str = None
|
||||||
):
|
):
|
||||||
|
|
||||||
ui.add_head_html("""
|
ui.add_head_html("""
|
||||||
@@ -15,8 +18,15 @@ async def frame(
|
|||||||
await ui.context.client.connected()
|
await ui.context.client.connected()
|
||||||
|
|
||||||
is_login = await ui.run_javascript('is_login()', timeout=3)
|
is_login = await ui.run_javascript('is_login()', timeout=3)
|
||||||
if str(is_login).lower() != 'true' and page != 'session':
|
if str(is_login).lower() != 'true':
|
||||||
ui.navigate.to('/login?redirect_to=/admin')
|
if page not in ['session', 'found']:
|
||||||
|
ui.navigate.to(f'/login?redirect_to={request.url.path}')
|
||||||
|
else:
|
||||||
|
if page == 'session':
|
||||||
|
ui.navigate.to(redirect_to)
|
||||||
|
|
||||||
|
if page != 'found':
|
||||||
|
ui.dark_mode(value=True)
|
||||||
|
|
||||||
with ui.header() \
|
with ui.header() \
|
||||||
.classes('items-center py-2 px-5 no-wrap').props('elevated'):
|
.classes('items-center py-2 px-5 no-wrap').props('elevated'):
|
||||||
@@ -31,7 +41,7 @@ async def frame(
|
|||||||
|
|
||||||
with ui.left_drawer() as left_drawer:
|
with ui.left_drawer() as left_drawer:
|
||||||
with ui.column(align_items='center').classes('w-full'):
|
with ui.column(align_items='center').classes('w-full'):
|
||||||
ui.image('/static/Findreve.png').classes('w-1/2 mx-auto')
|
ui.image('/static/Findreve.png').classes('w-1/3 mx-auto')
|
||||||
ui.label('Findreve').classes('text-2xl text-bold')
|
ui.label('Findreve').classes('text-2xl text-bold')
|
||||||
ui.label("免费版,无需授权").classes('text-sm text-gray-500')
|
ui.label("免费版,无需授权").classes('text-sm text-gray-500')
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
|||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi import Request
|
|
||||||
from .framework import frame
|
from .framework import frame
|
||||||
|
|
||||||
def create() -> Optional[RedirectResponse]:
|
def create() -> Optional[RedirectResponse]:
|
||||||
@@ -23,9 +22,11 @@ def create() -> Optional[RedirectResponse]:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
ui.page_title('登录 Findreve')
|
ui.page_title('登录 Findreve')
|
||||||
ui.dark_mode(True)
|
|
||||||
|
|
||||||
async with frame(page='session'):
|
async with frame(page='session', redirect_to=redirect_to):
|
||||||
|
|
||||||
|
await ui.context.client.connected()
|
||||||
|
|
||||||
async def login():
|
async def login():
|
||||||
if username.value == "" or password.value == "":
|
if username.value == "" or password.value == "":
|
||||||
ui.notify('账号或密码不能为空', color='negative')
|
ui.notify('账号或密码不能为空', color='negative')
|
||||||
|
|||||||
@@ -24,22 +24,7 @@ def create() -> None:
|
|||||||
|
|
||||||
# 添加页面过渡动画
|
# 添加页面过渡动画
|
||||||
ui.add_head_html('''
|
ui.add_head_html('''
|
||||||
<style>
|
<style type="text/css" src="/static/css/main_page.css"></style>
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.8s ease-in-out;
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
body > div {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeIn 0.8s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
.transform {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
''')
|
''')
|
||||||
|
|
||||||
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.row(align_items='center').classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl p-24'):
|
||||||
|
|||||||
21
static/css/about.css
Normal file
21
static/css/about.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
a:link:not(.browser-window *),
|
||||||
|
a:visited:not(.browser-window *) {
|
||||||
|
color: inherit !important;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover:not(.browser-window *),
|
||||||
|
a:active:not(.browser-window *) {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bold-links a:link {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-links a:link:not(.auto-link)::after {
|
||||||
|
content: "north_east";
|
||||||
|
font-family: "Material Icons";
|
||||||
|
font-weight: 100;
|
||||||
|
vertical-align: -10%;
|
||||||
|
}
|
||||||
14
static/css/main_page.css
Normal file
14
static/css/main_page.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.8s ease-in-out;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
body > div {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.8s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
.transform {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
25
tool.py
25
tool.py
@@ -15,7 +15,12 @@ import logging
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import os
|
import os
|
||||||
|
|
||||||
def format_phone(phone: str, groups: list = None, separator: str = " ", private: bool = False) -> str:
|
def format_phone(
|
||||||
|
phone: str,
|
||||||
|
groups: list[int] | None = None,
|
||||||
|
separator: str = " ",
|
||||||
|
private: bool = False
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
格式化中国大陆的11位手机号
|
格式化中国大陆的11位手机号
|
||||||
|
|
||||||
@@ -41,7 +46,9 @@ 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:
|
||||||
"""
|
"""
|
||||||
生成指定长度的随机密码。
|
生成指定长度的随机密码。
|
||||||
|
|
||||||
@@ -54,7 +61,9 @@ def generate_password(length: int = 8) -> str:
|
|||||||
|
|
||||||
return secrets.token_hex(length)
|
return secrets.token_hex(length)
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(
|
||||||
|
password: str
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
生成密码的加盐哈希值。
|
生成密码的加盐哈希值。
|
||||||
|
|
||||||
@@ -70,7 +79,11 @@ def hash_password(password: str) -> str:
|
|||||||
pwdhash = binascii.hexlify(pwdhash)
|
pwdhash = binascii.hexlify(pwdhash)
|
||||||
return (salt + pwdhash).decode('ascii')
|
return (salt + pwdhash).decode('ascii')
|
||||||
|
|
||||||
def verify_password(stored_password: str, provided_password: str, debug: bool = False) -> bool:
|
def verify_password(
|
||||||
|
stored_password: str,
|
||||||
|
provided_password: str,
|
||||||
|
debug: bool = False
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
验证存储的密码哈希值与用户提供的密码是否匹配。
|
验证存储的密码哈希值与用户提供的密码是否匹配。
|
||||||
|
|
||||||
@@ -96,7 +109,9 @@ def verify_password(stored_password: str, provided_password: str, debug: bool =
|
|||||||
logging.info(f"原密码: {provided_password}, 哈希值: {pwdhash}, 存储哈希值: {stored_password}")
|
logging.info(f"原密码: {provided_password}, 哈希值: {pwdhash}, 存储哈希值: {stored_password}")
|
||||||
return pwdhash == stored_password
|
return pwdhash == stored_password
|
||||||
|
|
||||||
def format_time_diff(target_time: datetime | str) -> str:
|
def format_time_diff(
|
||||||
|
target_time: datetime | str
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
计算目标时间与当前时间的差值,返回易读的中文描述
|
计算目标时间与当前时间的差值,返回易读的中文描述
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user