This commit is contained in:
2025-04-18 12:14:26 +08:00
parent fc87152f69
commit 2a217c4b8c
18 changed files with 180 additions and 363 deletions

56
.github/copilot-instructions.md vendored Normal file
View 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 规范
- 文档应随代码更改自动更新

View File

@@ -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
View File

@@ -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'))

View File

@@ -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
View File

@@ -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')

View File

@@ -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)

Binary file not shown.

View File

@@ -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)

View File

@@ -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'):

View File

@@ -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')

View File

@@ -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')

View File

@@ -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

View File

@@ -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')

View File

@@ -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')

View File

@@ -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
View 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
View 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
View File

@@ -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:
""" """
计算目标时间与当前时间的差值,返回易读的中文描述 计算目标时间与当前时间的差值,返回易读的中文描述