diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..ed78fe0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 规范 +- 文档应随代码更改自动更新 diff --git a/CHANGELOG.md b/CHANGELOG.md index 97daccd..553dd59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Findreve 更新日志 +## V1.3.1 2025-04-18 +- 新增 Github Copilot 指令 + +- 优化 管理员鉴权的代码逻辑 +- 优化 Framework 左侧的 Logo 尺寸显示 +- 优化 管理员面板跳转的重定向显示 +- 优化 CSS 的保存位置 +- 优化 代码结构 + +- 修复 登录后访问登录页面不跳转 + +- 更新 requirements.txt + +- 删除 一个历史遗留组件 + ## V1.3.0 2025-04-18 - 新增 全站前后端分离 - 新增 物品页懒加载 diff --git a/JWT.py b/JWT.py index bd0437b..b10db97 100644 --- a/JWT.py +++ b/JWT.py @@ -2,6 +2,8 @@ from fastapi.security import OAuth2PasswordBearer from model import database import asyncio -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token") +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl="/api/token" + ) SECRET_KEY = asyncio.run(database.Database().get_setting('SECRET_KEY')) \ No newline at end of file diff --git a/README.md b/README.md index eac8b23..26dbe70 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@
Homepage • Demo • - Documents • + ChangeLog • Download • License
diff --git a/auth.py b/auth.py deleted file mode 100644 index a881de9..0000000 --- a/auth.py +++ /dev/null @@ -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(""" - - """) - 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(""" - - """) - - 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') \ No newline at end of file diff --git a/notfound.py b/notfound.py index 6a8f54a..4a99618 100644 --- a/notfound.py +++ b/notfound.py @@ -15,5 +15,5 @@ from fastapi.responses import HTMLResponse def create() -> None: @app.get('/404') - async def not_found_page(request: Request) -> None: + async def not_found_page(request: Request) -> HTMLResponse: return HTMLResponse(status_code=404) diff --git a/requirements.txt b/requirements.txt index b901a95..127107d 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/routes/backend/admin.py b/routes/backend/admin.py index 352d9b9..6c7d821 100644 --- a/routes/backend/admin.py +++ b/routes/backend/admin.py @@ -8,8 +8,6 @@ from model import database from model.response import DefaultResponse from model.items import Item -Router = APIRouter(prefix='/api/admin', tags=['admin']) - async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -27,17 +25,22 @@ async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]): raise credentials_exception return True +Router = APIRouter( + prefix='/api/admin', + tags=['admin'], + dependencies=[Depends(is_admin)] +) + @Router.get('/') -async def is_admin( +async def verity_admin( is_admin: Annotated[str, Depends(is_admin)] ): return is_admin @Router.get('/items') async def get_items( - is_admin: Annotated[str, Depends(is_admin)], - id: int = None, - key: str = None): + id: Optional[int] = None, + key: Optional[str] = None): results = await database.Database().get_object(id=id, key=key) if results is not None: @@ -65,7 +68,6 @@ async def get_items( @Router.post('/items') async def add_items( - is_admin: Annotated[str, Depends(is_admin)], key: str, name: str, icon: str, @@ -75,13 +77,12 @@ async def add_items( @Router.patch('/items') async def update_items( - is_admin: Annotated[str, Depends(is_admin)], id: int, - key: str = None, - name: str = None, - icon: str = None, - status: str = None, - phone: int = None, + key: Optional[str] = None, + name: Optional[str] = None, + icon: Optional[str] = None, + status: Optional[str] = None, + phone: Optional[int] = None, lost_description: Optional[str] = None, find_ip: Optional[str] = None, lost_time: Optional[str] = None): @@ -99,7 +100,6 @@ async def update_items( @Router.delete('/items') async def delete_items( - is_admin: Annotated[str, Depends(is_admin)], id: int): try: await database.Database().delete_object(id=id) diff --git a/routes/frontend/admin/about.py b/routes/frontend/admin/about.py index 1d20e6c..736bbb0 100644 --- a/routes/frontend/admin/about.py +++ b/routes/frontend/admin/about.py @@ -10,49 +10,20 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved. ''' from nicegui import ui +from fastapi import Request from tool import * from ..framework import frame def create(): @ui.page('/admin/about') - async def admin_about(): - - dark_mode = ui.dark_mode(value=True) - - ui.add_head_html( - code=""" - - """ - ) - + async def admin_about(request: Request): ui.add_head_html(""" + """) - async with frame(): + async with frame(request=request): # 关于 Findreve with ui.tab_panel('about'): diff --git a/routes/frontend/admin/auth.py b/routes/frontend/admin/auth.py index edffecc..b948cd4 100644 --- a/routes/frontend/admin/auth.py +++ b/routes/frontend/admin/auth.py @@ -11,17 +11,15 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved. from nicegui import ui from tool import * +from fastapi import Request from ..framework import frame def create(): @ui.page('/admin/auth') - async def admin_auth(): - - dark_mode = ui.dark_mode(value=True) - + async def admin_auth(request: Request): # Findreve 授权 - async with frame(): + async with frame(request=request): ui.label('Findreve 授权').classes('text-2xl text-bold') diff --git a/routes/frontend/admin/home.py b/routes/frontend/admin/home.py index ed759ff..f9bd8c6 100644 --- a/routes/frontend/admin/home.py +++ b/routes/frontend/admin/home.py @@ -9,22 +9,21 @@ Description: Findreve 后台管理 admin 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 ..framework import frame def create(): - @ui.page('/admin') + @app.get('/admin') async def jump(): - ui.navigate.to('/admin/home') - + return RedirectResponse(url='/admin/home') + @ui.page('/admin/home') - async def admin_home(): - - dark_mode = ui.dark_mode(value=True) - - async with frame(): + async def admin_home(request: Request): + async with frame(request=request): with ui.tab_panel('main_page'): ui.label('首页配置').classes('text-2xl text-bold') ui.label('暂不支持,请直接修改main_page.py').classes('text-md text-gray-600').classes('w-full') \ No newline at end of file diff --git a/routes/frontend/found.py b/routes/frontend/found.py index 43ca5af..2d796c0 100644 --- a/routes/frontend/found.py +++ b/routes/frontend/found.py @@ -94,7 +94,7 @@ def create() -> None: await ui.context.client.connected() - async with frame(page='found'): + async with frame(page='found', request=request): if key == "" or key == None: ui.navigate.to('/404') return diff --git a/routes/frontend/framework.py b/routes/frontend/framework.py index 8589bff..bb0d67e 100644 --- a/routes/frontend/framework.py +++ b/routes/frontend/framework.py @@ -1,11 +1,14 @@ from contextlib import asynccontextmanager from nicegui import ui import asyncio +from fastapi import Request from typing import Optional, Literal @asynccontextmanager 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(""" @@ -15,8 +18,15 @@ async def frame( await ui.context.client.connected() is_login = await ui.run_javascript('is_login()', timeout=3) - if str(is_login).lower() != 'true' and page != 'session': - ui.navigate.to('/login?redirect_to=/admin') + if str(is_login).lower() != 'true': + 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() \ .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.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("免费版,无需授权").classes('text-sm text-gray-500') diff --git a/routes/frontend/login.py b/routes/frontend/login.py index dacc377..c00b467 100644 --- a/routes/frontend/login.py +++ b/routes/frontend/login.py @@ -12,7 +12,6 @@ Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved. from nicegui import ui from typing import Optional from fastapi.responses import RedirectResponse -from fastapi import Request from .framework import frame def create() -> Optional[RedirectResponse]: @@ -23,9 +22,11 @@ def create() -> Optional[RedirectResponse]: """) 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(): if username.value == "" or password.value == "": ui.notify('账号或密码不能为空', color='negative') diff --git a/routes/frontend/main_page.py b/routes/frontend/main_page.py index 9d41b58..d5b731d 100644 --- a/routes/frontend/main_page.py +++ b/routes/frontend/main_page.py @@ -24,22 +24,7 @@ def create() -> None: # 添加页面过渡动画 ui.add_head_html(''' - + ''') with ui.row(align_items='center').classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl p-24'): diff --git a/static/css/about.css b/static/css/about.css new file mode 100644 index 0000000..20fb92b --- /dev/null +++ b/static/css/about.css @@ -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%; +} \ No newline at end of file diff --git a/static/css/main_page.css b/static/css/main_page.css new file mode 100644 index 0000000..5a7a3b6 --- /dev/null +++ b/static/css/main_page.css @@ -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; +} \ No newline at end of file diff --git a/tool.py b/tool.py index 9a3e20e..2129ef0 100644 --- a/tool.py +++ b/tool.py @@ -15,7 +15,12 @@ import logging from datetime import datetime, timezone 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位手机号 @@ -41,7 +46,9 @@ def format_phone(phone: str, groups: list = None, separator: str = " ", private: 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) -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) 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}") return pwdhash == stored_password -def format_time_diff(target_time: datetime | str) -> str: +def format_time_diff( + target_time: datetime | str + ) -> str: """ 计算目标时间与当前时间的差值,返回易读的中文描述