V1.0.0
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
data.db
|
||||
|
||||
# environment
|
||||
.venv/
|
||||
|
||||
# NiceGUI Data
|
||||
.nicegui/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
41
README.md
Normal file
41
README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
<!--
|
||||
* @Author: 于小丘 海枫
|
||||
* @Date: 2024-11-29 20:06:02
|
||||
* @LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
* @LastEditTime: 2024-11-29 20:28:54
|
||||
* @FilePath: /Findreve/README.md
|
||||
* @Description: Findreve
|
||||
*
|
||||
* Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
-->
|
||||
|
||||
<p style="text-align: center; font-size: 32px;">Findreve</p>
|
||||
|
||||
***
|
||||
|
||||
<p style="text-align: center;">Track, Tag, and Retrieve – Simplifying Item Recovery.</p>
|
||||
|
||||
Findreve is a powerful yet intuitive solution designed to help you manage your belongings and ensure their safe return if lost. Each item is assigned a unique ID and generates a secure link, easily embedded into a QR code or NFC tag. When scanned, the code directs finders to a dedicated webpage displaying item details and your contact information, ensuring privacy and ease of communication. Whether you’re managing personal belongings or professional assets, Findreve bridges the gap between lost and found with efficiency and simplicity.
|
||||
|
||||
## Install
|
||||
`Findreve` is a Python-based application. You need to have Python 3.8 or higher installed on your server. Then, clone this repository to your server and install the required dependencies:
|
||||
|
||||
NiceGUI: `pip3 install nicegui==2.5.0`
|
||||
|
||||
aiosqlite: `pip3 install aiosqlite`
|
||||
|
||||
## Launch
|
||||
Run the following command to start Findreve:
|
||||
|
||||
```shell
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
Upon launch, Findreve will create a SQLite database in the project's root directory and display the administrator's account and password in the console.
|
||||
|
||||
## License
|
||||
Findreve is available in two versions:
|
||||
|
||||
Open Source Free Version: Licensed under the `GPLv3`.
|
||||
|
||||
Donation-Based Premium Version: By making a donation, you can access a version with additional features and source code, which allows further development for personal or internal use. However, redistribution of the modified or original source code is not permitted.
|
||||
248
admin.py
Normal file
248
admin.py
Normal file
@@ -0,0 +1,248 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:43:28
|
||||
FilePath: /Findreve/admin.py
|
||||
Description: Findreve 后台管理 admin
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
from nicegui import ui, app
|
||||
from typing import Optional
|
||||
from typing import Dict
|
||||
import model
|
||||
import qrcode
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from fastapi import Request
|
||||
from tool import *
|
||||
|
||||
|
||||
def create(unitTest: bool = False):
|
||||
@ui.page('/admin' if not unitTest else '/')
|
||||
async def admin(request: Request):
|
||||
|
||||
dark_mode = ui.dark_mode(value=True)
|
||||
|
||||
# 表格列的显示隐藏开关
|
||||
def tableToggle(column: Dict, visible: bool, table) -> None:
|
||||
column['classes'] = '' if visible else 'hidden'
|
||||
column['headerClasses'] = '' if visible else 'hidden'
|
||||
table.update()
|
||||
|
||||
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 仪表盘" if not unitTest else 'Findreve 仪表盘 单测模式').classes('text-lg').props('flat color=white no-caps')
|
||||
|
||||
with ui.left_drawer() as left_drawer:
|
||||
ui.image('https://bing.img.run/1366x768.php').classes('w-full')
|
||||
with ui.row(align_items='center').classes('w-full'):
|
||||
ui.label('Findreve').classes('text-2xl text-bold')
|
||||
ui.chip('Pro').classes('text-xs -left-3').props('floating outline')
|
||||
ui.label("本地模式无需授权").classes('text-gray-600 -mt-3')
|
||||
|
||||
ui.button('物品 & 库存', icon='settings', on_click=lambda: tabs.set_value('item')) \
|
||||
.classes('w-full').props('flat no-caps')
|
||||
ui.button('产品 & 授权', icon='settings', on_click=lambda: tabs.set_value('auth')) \
|
||||
.classes('w-full').props('flat no-caps')
|
||||
ui.button('关于 & 反馈', icon='settings', on_click=lambda: tabs.set_value('about')) \
|
||||
.classes('w-full').props('flat no-caps')
|
||||
|
||||
with ui.tab_panels(tabs, value='item').classes('w-full').props('vertical'):
|
||||
|
||||
# 物品一览
|
||||
with ui.tab_panel('item'):
|
||||
|
||||
# 添加物品
|
||||
async def addObject():
|
||||
dialogAddObjectIcon.disable()
|
||||
if object_name.value == "" or object_icon == "" or object_phone == "":
|
||||
ui.notify('必填字段不能为空', color='negative')
|
||||
dialogAddObjectIcon.enable()
|
||||
return
|
||||
|
||||
if not object_phone.validate():
|
||||
ui.notify('号码输入有误,请检查!', color='negative')
|
||||
dialogAddObjectIcon.enable()
|
||||
return
|
||||
|
||||
if object_key.value == "":
|
||||
object_key.set_value(generate_password())
|
||||
|
||||
try:
|
||||
await model.Database().add_object(key=object_key.value, name=object_name.value, icon=object_icon.value, phone=object_phone.value)
|
||||
except ValueError as e:
|
||||
ui.notify(str(e), color='negative')
|
||||
else:
|
||||
await reloadTable(tips=False)
|
||||
with ui.dialog() as addObjectSuccessDialog, ui.card().style('width: 90%; max-width: 500px'):
|
||||
ui.button(icon='done').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
|
||||
ui.label('添加成功').classes('w-full text-h5 text-center')
|
||||
|
||||
ui.label('你可以使用下面的链接来访问这个物品')
|
||||
ui.code(request.base_url.hostname+ '/found?key=' + object_key.value).classes('w-full')
|
||||
|
||||
# 生成二维码
|
||||
qr_data = request.base_url.hostname + '/found?key=' + object_key.value
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill='black', back_color='white')
|
||||
|
||||
# 将二维码转换为Base64
|
||||
buffered = BytesIO()
|
||||
img.save(buffered, format="PNG")
|
||||
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||
|
||||
# 展示二维码
|
||||
ui.image(f'data:image/png;base64,{img_str}')
|
||||
|
||||
# 添加下载二维码按钮
|
||||
ui.button("下载二维码", on_click=lambda: ui.download(buffered.getvalue(), 'qrcode.png')) \
|
||||
.classes('w-full').props('flat rounded')
|
||||
|
||||
ui.button("返回", on_click=lambda: (addObjectDialog.close(), addObjectSuccessDialog.close(), addObjectSuccessDialog.delete())) \
|
||||
.classes('w-full').props('flat rounded')
|
||||
|
||||
addObjectSuccessDialog.open()
|
||||
|
||||
|
||||
|
||||
# 添加物品对话框
|
||||
with ui.dialog() as addObjectDialog, ui.card().style('width: 90%; max-width: 500px'):
|
||||
ui.button(icon='add_circle').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
|
||||
ui.label('添加物品').classes('w-full text-h5 text-center')
|
||||
|
||||
with ui.scroll_area().classes('w-full'):
|
||||
object_name = ui.input('物品名称').classes('w-full')
|
||||
object_name_tips = ui.label('显示的物品名称').classes('-mt-3')
|
||||
with ui.row(align_items='center').classes('w-full'):
|
||||
with ui.column().classes('w-1/2 flex-grow'):
|
||||
object_icon = ui.input('物品图标').classes('w-full')
|
||||
with ui.row(align_items='center').classes('-mt-3'):
|
||||
ui.label('将在右侧实时预览图标')
|
||||
object_icon_link = ui.link('图标列表', 'https://fonts.google.com/icons?icon.set=Material+Icons')
|
||||
object_icon_preview = ui.icon('').classes('text-2xl flex-grow').bind_name_from(object_icon, 'value')
|
||||
object_phone = ui.input('物品绑定手机号', validation={'请输入中国大陆格式的11位手机号': lambda value: len(value) == 11 and value.isdigit()}).classes('w-full')
|
||||
object_phone_tips = ui.label('目前仅支持中国大陆格式的11位手机号').classes('-mt-3')
|
||||
object_key = ui.input('物品Key(可选,不填自动生成)').classes('w-full')
|
||||
object_key_tips = ui.label('物品Key为物品的唯一标识,可用于物品找回').classes('-mt-3')
|
||||
|
||||
async def handle_add_object():
|
||||
await addObject()
|
||||
|
||||
dialogAddObjectIcon = ui.button("添加并生成二维码", icon='qr_code', on_click=handle_add_object) \
|
||||
.classes('items-center w-full').props('rounded')
|
||||
ui.button("返回", on_click=addObjectDialog.close) \
|
||||
.classes('w-full').props('flat rounded')
|
||||
|
||||
async def reloadTable(tips: bool = True):
|
||||
# 获取所有物品
|
||||
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()]
|
||||
status_map = {'ok': '正常', 'lost': '丢失'}
|
||||
for obj in objects:
|
||||
obj['status'] = status_map.get(obj['status'], obj['status'])
|
||||
if obj['create_at']:
|
||||
obj['create_at'] = format_time_diff(obj['create_at'])
|
||||
if obj['lost_at']:
|
||||
obj['lost_at'] = format_time_diff(obj['lost_at'])
|
||||
object_table.update_rows(objects)
|
||||
if tips:
|
||||
ui.notify('刷新成功')
|
||||
|
||||
# 获取所有物品
|
||||
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()]
|
||||
status_map = {'ok': '正常', 'lost': '丢失'}
|
||||
for obj in objects:
|
||||
obj['status'] = status_map.get(obj['status'], obj['status'])
|
||||
if obj['create_at']:
|
||||
obj['create_at'] = format_time_diff(obj['create_at'])
|
||||
if obj['lost_at']:
|
||||
obj['lost_at'] = format_time_diff(obj['lost_at'])
|
||||
object_columns=[
|
||||
{'name': 'id', 'label': '内部ID', 'field': 'id', 'required': True, 'align': 'left'},
|
||||
{'name': 'key', 'label': '物品Key', 'field': 'key', 'required': True, 'align': 'left'},
|
||||
{'name': 'name', 'label': '物品名称', 'field': 'name', 'required': True, 'align': 'left'},
|
||||
{'name': 'icon', 'label': '物品图标', 'field': 'icon', 'required': True, 'align': 'left'},
|
||||
{'name': 'phone', 'label': '物品绑定手机', 'field': 'phone', 'required': True, 'align': 'left'},
|
||||
{'name': 'create_at', 'label': '物品创建时间', 'field': 'create_at', 'required': True, 'align': 'left'}
|
||||
]
|
||||
object_table = ui.table(
|
||||
title='物品 & 库存',
|
||||
row_key='id',
|
||||
pagination=10,
|
||||
selection='single',
|
||||
columns=object_columns,
|
||||
rows=objects
|
||||
).classes('w-full').props('flat')
|
||||
|
||||
|
||||
with object_table.add_slot('top-right'):
|
||||
|
||||
ui.input('搜索物品').classes('px-2') \
|
||||
.bind_value(object_table, 'filter') \
|
||||
.props('rounded outlined dense clearable')
|
||||
|
||||
ui.button(icon='refresh', on_click=lambda: reloadTable()).classes('px-2').props('flat fab-mini')
|
||||
|
||||
with ui.button(icon='menu').classes('px-2').props('flat fab-mini'):
|
||||
with ui.menu(), ui.column().classes('gap-0 p-4'):
|
||||
for column in object_columns:
|
||||
ui.switch(column['label'], value=True, on_change=lambda e,
|
||||
column=column: tableToggle(column=column, visible=e.value, table=object_table))
|
||||
# FAB按钮
|
||||
with ui.page_sticky(x_offset=24, y_offset=24) as addObjectFAB:
|
||||
ui.button(icon='add', on_click=addObjectDialog.open) \
|
||||
.props('fab')
|
||||
|
||||
# Findreve 授权
|
||||
with ui.tab_panel('auth'):
|
||||
|
||||
ui.label('Findreve 授权').classes('text-2xl text-bold')
|
||||
|
||||
with ui.element('div').classes('p-2 bg-orange-100 w-full'):
|
||||
with ui.row(align_items='center'):
|
||||
ui.icon('favorite').classes('text-rose-500 text-2xl')
|
||||
ui.label('感谢您使用 Findreve').classes('text-rose-500 text-bold')
|
||||
with ui.column():
|
||||
ui.markdown('> 使用付费版本请在下方进行授权验证'
|
||||
'<br>'
|
||||
'Findreve 是一款良心、厚道的好产品!创作不易,支持正版,从我做起!'
|
||||
'<br>'
|
||||
'如需在生产环境部署请前往 `auth.yxqi.cn` 购买正版'
|
||||
).classes('text-rose-500')
|
||||
ui.markdown('- Findreve 官网:[https://auth.yxqi.cn](https://auth.yxqi.cn)\n'
|
||||
'- 作者联系方式:QQ 2372526808\n'
|
||||
'- 管理我的授权:[https://auth.yxqi.cn/product/5](https://auth.yxqi.cn/product/5)\n'
|
||||
).classes('text-rose-500')
|
||||
ui.label('您正在使用免费版本,无需授权可体验完整版Findreve。').classes('text-bold')
|
||||
|
||||
# 关于 Findreve
|
||||
with ui.tab_panel('about'):
|
||||
ui.label('关于 Findreve').classes('text-2xl text-bold')
|
||||
|
||||
ui.label('还在做')
|
||||
|
||||
if __name__ in {"__main__", "__mp_main__"}:
|
||||
create(unitTest=True)
|
||||
ui.run(
|
||||
host='0.0.0.0',
|
||||
favicon='🚀',
|
||||
port=8080,
|
||||
title='Findreve',
|
||||
native=False,
|
||||
storage_secret='findreve',
|
||||
language='zh-CN',
|
||||
fastapi_docs=False)
|
||||
81
found.py
Normal file
81
found.py
Normal file
@@ -0,0 +1,81 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:35:19
|
||||
FilePath: /Findreve/found.py
|
||||
Description: Findreve 物品详情页 found
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
from nicegui import ui
|
||||
from fastapi import Request
|
||||
from model import Database
|
||||
from tool import format_phone
|
||||
|
||||
def create() -> None:
|
||||
@ui.page('/found')
|
||||
async def found_page(key: str = "") -> None:
|
||||
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)'):
|
||||
|
||||
ui.button(icon='menu').props('flat color=white round')
|
||||
ui.button(text="Findreve").classes('text-lg').props('flat color=white no-caps')
|
||||
|
||||
if key == "" or key == None:
|
||||
ui.navigate.to('/404')
|
||||
|
||||
# 加载dialog
|
||||
with ui.dialog().props('persistent') as loading, ui.card():
|
||||
with ui.row(align_items='center'):
|
||||
ui.spinner(size='lg')
|
||||
with ui.column():
|
||||
ui.label('数据加载中...')
|
||||
ui.label('Loading...').classes('text-xs text-gray-500 -mt-3')
|
||||
|
||||
loading.open()
|
||||
|
||||
db = Database()
|
||||
await db.init_db()
|
||||
object_data = await db.get_object(key=key)
|
||||
|
||||
if not object_data:
|
||||
with ui.card().classes('absolute-center w-3/4 h-2/3'):
|
||||
|
||||
# 添加标题
|
||||
ui.button(icon='error', color='red').props('outline round').classes('mx-auto w-auto shadow-sm w-fill max-md:hidden')
|
||||
ui.label('物品不存在').classes('text-h5 w-full text-center')
|
||||
ui.label('Object not found').classes('text-xs w-full text-center text-gray-500 -mt-3')
|
||||
|
||||
ui.label('请输入正确的序列号').classes('text-md w-full text-center')
|
||||
ui.label('Please enter the correct serial number').classes('text-md w-full text-center')
|
||||
|
||||
else:
|
||||
# 物品存在, 但未标记为丢失
|
||||
with ui.card().classes('absolute-center w-3/4 h-3/4'):
|
||||
|
||||
# 添加标题
|
||||
ui.button(icon=object_data[3]).props('outline round').classes('mx-auto w-auto shadow-sm w-fill max-md:hidden')
|
||||
ui.label('关于此 '+ object_data[2]).classes('text-h5 w-full text-center')
|
||||
ui.label('About this '+ object_data[2]).classes('text-xs w-full text-center text-gray-500 -mt-3')
|
||||
|
||||
ui.label('序列号(Serial number):'+ object_data[1]).classes('text-md w-full text-center')
|
||||
|
||||
ui.label('物主(Owner):'+ format_phone(object_data[5], private=True)).classes('text-md w-full text-center')
|
||||
|
||||
ui.space()
|
||||
|
||||
ui.label('如果你意外捡到了此物品,请尽快联系物主。').classes('text-md w-full text-center')
|
||||
ui.label('If you accidentally picked it up, please contact the owner as soon as possible. ').classes('text-xs w-full text-center text-gray-500 -mt-3')
|
||||
|
||||
|
||||
ui.button('返回首页',
|
||||
on_click=lambda: ui.navigate.to('/')) \
|
||||
.classes('items-center w-full').props('rounded')
|
||||
|
||||
|
||||
loading.close()
|
||||
loading.clear()
|
||||
return
|
||||
84
login.py
Normal file
84
login.py
Normal file
@@ -0,0 +1,84 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:29:26
|
||||
FilePath: /Findreve/login.py
|
||||
Description: Findreve 登录界面 Login
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
from nicegui import ui, app
|
||||
from typing import Optional
|
||||
import traceback
|
||||
import asyncio
|
||||
import model
|
||||
import tool
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
def create(unitTest: bool = False) -> Optional[RedirectResponse]:
|
||||
@ui.page('/login' if not unitTest else '/')
|
||||
async def session():
|
||||
# 检测是否已登录
|
||||
if app.storage.user.get('authenticated', False):
|
||||
return ui.navigate.to('/admin')
|
||||
|
||||
ui.page_title('登录 Findreve')
|
||||
async def try_login() -> None:
|
||||
app.storage.user.update({'authenticated': True})
|
||||
# 跳转到用户上一页
|
||||
ui.navigate.to(app.storage.user.get('referrer_path', '/'))
|
||||
|
||||
async def login():
|
||||
if username.value == "" or password.value == "":
|
||||
ui.notify('账号或密码不能为空', color='negative')
|
||||
return
|
||||
|
||||
# 验证账号和密码
|
||||
account = await model.Database().get_setting('account')
|
||||
stored_password = await model.Database().get_setting('password')
|
||||
|
||||
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(app.storage.user.get('referrer_path', '/'))
|
||||
|
||||
|
||||
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)'):
|
||||
|
||||
ui.button(icon='menu').props('flat color=white round')
|
||||
appBar_appName = ui.button(text="HeyPress" if not unitTest else 'HeyPress 单测模式').classes('text-lg').props('flat color=white no-caps')
|
||||
|
||||
# 创建一个绝对中心的登录卡片
|
||||
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.label('登录 HeyPress').classes('text-h5 w-full text-center')
|
||||
# 用户名/密码框
|
||||
username = ui.input('账号').on('keydown.enter', try_login) \
|
||||
.classes('block w-full text-gray-900').props('rounded outlined')
|
||||
password = ui.input('密码', password=True, password_toggle_button=True) \
|
||||
.on('keydown.enter', try_login).classes('block w-full text-gray-900').props('rounded outlined')
|
||||
|
||||
# 按钮布局
|
||||
ui.button('登录', on_click=lambda: login()).classes('items-center w-full').props('rounded')
|
||||
|
||||
|
||||
if __name__ in {"__main__", "__mp_main__"}:
|
||||
create(unitTest=True)
|
||||
ui.run(
|
||||
host='0.0.0.0',
|
||||
favicon='🚀',
|
||||
port=8080,
|
||||
title='Findreve',
|
||||
native=False,
|
||||
storage_secret='findreve',
|
||||
language='zh-CN',
|
||||
fastapi_docs=False)
|
||||
87
main.py
Normal file
87
main.py
Normal file
@@ -0,0 +1,87 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:04:41
|
||||
FilePath: /Findreve/main.py
|
||||
Description: Findreve
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
from nicegui import app, ui, Client
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
import hashlib
|
||||
import inspect
|
||||
|
||||
import notfound
|
||||
import main_page
|
||||
import found
|
||||
import login
|
||||
import admin
|
||||
import model
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
notfound.create()
|
||||
main_page.create()
|
||||
found.create()
|
||||
login.create()
|
||||
admin.create()
|
||||
|
||||
# 中间件配置文件
|
||||
AUTH_CONFIG = {
|
||||
"restricted_routes": {'/admin'},
|
||||
"login_url": "/login",
|
||||
"cookie_name": "session",
|
||||
"session_expire": 3600 # 会话过期时间
|
||||
}
|
||||
|
||||
# 登录验证中间件 Login verification middleware
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
# 异步处理每个请求
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
try:
|
||||
logging.info(f"访问路径: {request.url.path},"
|
||||
f"认证状态: {app.storage.user.get('authenticated')}")
|
||||
if not app.storage.user.get('authenticated', False):
|
||||
# 如果请求的路径在Client.page_routes.values()中,并且不在unrestricted_page_routes中
|
||||
if request.url.path in Client.page_routes.values() \
|
||||
and request.url.path in AUTH_CONFIG["restricted_routes"]:
|
||||
logging.warning(f"未认证用户尝试访问: {request.url.path}")
|
||||
# 记录用户想访问的路径 Record the user's intended path
|
||||
app.storage.user['referrer_path'] = request.url.path
|
||||
# 重定向到登录页面 Redirect to the login page
|
||||
return RedirectResponse(AUTH_CONFIG["login_url"])
|
||||
# 否则,继续处理请求 Otherwise, continue processing the request
|
||||
return await call_next(request)
|
||||
|
||||
except Exception as e:
|
||||
# 记录错误日志
|
||||
logging.error(f"认证中间件错误: {str(e)}")
|
||||
# 返回适当的错误响应
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "服务器内部错误"}
|
||||
)
|
||||
|
||||
# 添加中间件 Add middleware
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
# 启动函数 Startup function
|
||||
def startup():
|
||||
asyncio.run(model.Database().init_db())
|
||||
ui.run(
|
||||
host='0.0.0.0',
|
||||
favicon='🚀',
|
||||
port=8080,
|
||||
title='Findreve',
|
||||
native=False,
|
||||
storage_secret='findreve',
|
||||
language='zh-CN',
|
||||
fastapi_docs=False)
|
||||
|
||||
if __name__ in {"__main__", "__mp_main__"}:
|
||||
startup()
|
||||
152
main_page.py
Normal file
152
main_page.py
Normal file
File diff suppressed because one or more lines are too long
173
model.py
Normal file
173
model.py
Normal file
@@ -0,0 +1,173 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:05:03
|
||||
FilePath: /Findreve/model.py
|
||||
Description: Findreve 数据库组件 model
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
import aiosqlite
|
||||
from datetime import datetime
|
||||
import tool
|
||||
import logging
|
||||
import os
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path: str = "data.db"):
|
||||
self.db_path = db_path
|
||||
|
||||
async def init_db(self):
|
||||
"""初始化数据库和表"""
|
||||
logging.info("开始初始化数据库和表")
|
||||
|
||||
create_objects_table = """
|
||||
CREATE TABLE IF NOT EXISTS fr_objects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
status TEXT,
|
||||
phone TEXT,
|
||||
context TEXT,
|
||||
find_ip TEXT,
|
||||
create_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
lost_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
create_settings_table = """
|
||||
CREATE TABLE IF NOT EXISTS fr_settings (
|
||||
type TEXT,
|
||||
name TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
"""
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
logging.info("连接到数据库")
|
||||
await db.execute(create_objects_table)
|
||||
logging.info("创建或验证fr_objects表")
|
||||
await db.execute(create_settings_table)
|
||||
logging.info("创建或验证fr_settings表")
|
||||
|
||||
# 初始化设置表数据
|
||||
async with db.execute("SELECT name FROM fr_settings WHERE name = 'version'") as cursor:
|
||||
if not await cursor.fetchone():
|
||||
await db.execute(
|
||||
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
|
||||
('string', 'version', '1.0.0')
|
||||
)
|
||||
logging.info("插入初始版本信息: version 1.0.0")
|
||||
|
||||
async with db.execute("SELECT name FROM fr_settings WHERE name = 'ver'") as cursor:
|
||||
if not await cursor.fetchone():
|
||||
await db.execute(
|
||||
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
|
||||
('int', 'ver', '1')
|
||||
)
|
||||
logging.info("插入初始版本号: ver 1")
|
||||
|
||||
async with db.execute("SELECT name FROM fr_settings WHERE name = 'account'") as cursor:
|
||||
if not await cursor.fetchone():
|
||||
account = 'admin@yuxiaoqiu.cn'
|
||||
await db.execute(
|
||||
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
|
||||
('string', 'account', account)
|
||||
)
|
||||
logging.info(f"插入初始账号信息: {account}")
|
||||
|
||||
async with db.execute("SELECT name FROM fr_settings WHERE name = 'password'") as cursor:
|
||||
if not await cursor.fetchone():
|
||||
password = tool.generate_password()
|
||||
hashed_password = tool.hash_password(password)
|
||||
await db.execute(
|
||||
"INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)",
|
||||
('string', 'password', hashed_password)
|
||||
)
|
||||
logging.info("插入初始密码信息")
|
||||
print(f"密码(请牢记,后续不再显示): {password}")
|
||||
|
||||
await db.commit()
|
||||
logging.info("数据库初始化完成并提交更改")
|
||||
|
||||
async def add_object(self, key: str, name: str, icon: str = None, phone: str = None):
|
||||
"""添加新对象
|
||||
|
||||
:param key: 序列号
|
||||
:param name: 名称
|
||||
:param icon: 图标
|
||||
:param phone: 电话
|
||||
"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute("SELECT 1 FROM fr_objects WHERE key = ?", (key,)) as cursor:
|
||||
if await cursor.fetchone():
|
||||
raise ValueError(f"序列号 {key} 已存在")
|
||||
|
||||
now = datetime.now()
|
||||
now = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
await db.execute(
|
||||
"INSERT INTO fr_objects (key, name, icon, phone, create_at, status) VALUES (?, ?, ?, ?, ?, 'ok')",
|
||||
(key, name, icon, phone, now)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def update_object(self, id: int, **kwargs):
|
||||
"""更新对象信息
|
||||
|
||||
:param id: 对象ID
|
||||
:param key: 序列号
|
||||
:param kwargs: 更新字段
|
||||
"""
|
||||
set_values = ", ".join([f"{k} = ?" for k in kwargs.keys()])
|
||||
values = tuple(kwargs.values())
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
f"UPDATE fr_objects SET {set_values} WHERE id = ?",
|
||||
(*values, id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def get_object(self, id: int = None, key: str = None):
|
||||
"""获取对象
|
||||
|
||||
:param id: 对象ID
|
||||
:param key: 序列号
|
||||
"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
if id is not None or key is not None:
|
||||
async with db.execute(
|
||||
"SELECT * FROM fr_objects WHERE id = ? OR key = ?", (id, key)
|
||||
) as cursor:
|
||||
return await cursor.fetchone()
|
||||
else:
|
||||
async with db.execute("SELECT * FROM fr_objects") as cursor:
|
||||
return await cursor.fetchall()
|
||||
|
||||
async def set_setting(self, name: str, value: str):
|
||||
"""设置配置项
|
||||
|
||||
:param name: 配置项名称
|
||||
:param value: 配置项值
|
||||
"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO fr_settings (name, value) VALUES (?, ?)",
|
||||
(name, value)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def get_setting(self, name: str):
|
||||
"""获取配置项
|
||||
|
||||
:param name: 配置项名称
|
||||
"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT value FROM fr_settings WHERE name = ?", (name,)
|
||||
) as cursor:
|
||||
result = await cursor.fetchone()
|
||||
return result[0] if result else None
|
||||
40
notfound.py
Normal file
40
notfound.py
Normal file
@@ -0,0 +1,40 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:05:13
|
||||
FilePath: /Findreve/notfound.py
|
||||
Description: Findreve 404
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
from nicegui import ui
|
||||
from fastapi import Request
|
||||
|
||||
def create() -> None:
|
||||
@ui.page('/404')
|
||||
async def not_found_page(request: Request, ref="") -> None:
|
||||
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)'):
|
||||
|
||||
ui.button(icon='menu').props('flat color=white round')
|
||||
ui.button(text="HeyPress").classes('text-lg').props('flat color=white no-caps')
|
||||
|
||||
with ui.card().classes('absolute-center w-3/4 h-2/3'):
|
||||
|
||||
# 添加标题
|
||||
ui.button(icon='error', color='red').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
|
||||
ui.label('404').classes('text-h3 w-full text-center')
|
||||
|
||||
ui.space()
|
||||
|
||||
ui.label('页面不存在').classes('text-2xl w-full text-center')
|
||||
ui.label('Page Not Found').classes('text-2xl w-full text-center')
|
||||
|
||||
ui.space()
|
||||
|
||||
ui.button('返回首页',
|
||||
on_click=lambda: ui.navigate.to('/')) \
|
||||
.classes('items-center w-full').props('rounded')
|
||||
152
tool.py
Normal file
152
tool.py
Normal file
@@ -0,0 +1,152 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:05:42
|
||||
FilePath: /Findreve/tool.py
|
||||
Description: Findreve 小工具 tool
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
import string
|
||||
import random
|
||||
import hashlib
|
||||
import binascii
|
||||
import logging
|
||||
import qrcode
|
||||
from typing import Optional
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import base64
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import os
|
||||
|
||||
def format_phone(phone: str, groups: list = None, separator: str = " ", private: bool = False) -> str:
|
||||
"""
|
||||
格式化中国大陆的11位手机号
|
||||
|
||||
:param phone: 手机号
|
||||
:param groups: 分组长度列表
|
||||
:param separator: 分隔符
|
||||
:param private: 是否隐藏前七位
|
||||
|
||||
:return: 格式化后的手机号
|
||||
"""
|
||||
if groups is None:
|
||||
groups = [3, 4, 4]
|
||||
|
||||
result = []
|
||||
start = 0
|
||||
for i, length in enumerate(groups):
|
||||
segment = phone[start:start + length]
|
||||
# 如果是private模式,将前两组(前7位)替换为星号
|
||||
if private and i < 2:
|
||||
segment = "*" * length
|
||||
result.append(segment)
|
||||
start += length
|
||||
|
||||
return separator.join(result)
|
||||
|
||||
def generate_password(length: int = 8) -> str:
|
||||
# 定义密码字符集,大小写字母和数字
|
||||
characters = string.ascii_letters + string.digits
|
||||
# 随机选择length个字符,生成密码
|
||||
password = ''.join(random.choice(characters) for i in range(length))
|
||||
return password
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
生成密码的加盐哈希值。
|
||||
|
||||
:param password: 需要哈希的原始密码
|
||||
:type password: str
|
||||
:return: 包含盐值和哈希值的字符串
|
||||
:rtype: str
|
||||
|
||||
使用SHA-256和PBKDF2算法对密码进行加盐哈希,返回盐值和哈希值的组合。
|
||||
"""
|
||||
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii')
|
||||
pwdhash = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
|
||||
pwdhash = binascii.hexlify(pwdhash)
|
||||
return (salt + pwdhash).decode('ascii')
|
||||
|
||||
def verify_password(stored_password: str, provided_password: str, debug: bool = False) -> bool:
|
||||
"""
|
||||
验证存储的密码哈希值与用户提供的密码是否匹配。
|
||||
|
||||
:param stored_password: 存储的密码哈希值(包含盐值)
|
||||
:type stored_password: str
|
||||
:param provided_password: 用户提供的密码
|
||||
:type provided_password: str
|
||||
:param debug: 是否输出调试信息,将会输出原密码和哈希值
|
||||
:type debug: bool
|
||||
:return: 如果密码匹配返回True,否则返回False
|
||||
:rtype: bool
|
||||
|
||||
从存储的密码哈希中提取盐值,使用相同的哈希算法验证用户提供的密码。
|
||||
"""
|
||||
salt = stored_password[:64]
|
||||
stored_password = stored_password[64:]
|
||||
pwdhash = hashlib.pbkdf2_hmac('sha256',
|
||||
provided_password.encode('utf-8'),
|
||||
salt.encode('ascii'),
|
||||
100000)
|
||||
pwdhash = binascii.hexlify(pwdhash).decode('ascii')
|
||||
if debug:
|
||||
logging.info(f"原密码: {provided_password}, 哈希值: {pwdhash}, 存储哈希值: {stored_password}")
|
||||
return pwdhash == stored_password
|
||||
|
||||
def format_time_diff(target_time: datetime | str) -> str:
|
||||
"""
|
||||
计算目标时间与当前时间的差值,返回易读的中文描述
|
||||
|
||||
Args:
|
||||
target_time: 目标时间,可以是datetime对象或时间字符串
|
||||
|
||||
Returns:
|
||||
str: 格式化的时间差描述,如"一年前"、"3个月前"等
|
||||
"""
|
||||
# 如果输入是字符串,先转换为datetime对象
|
||||
if isinstance(target_time, str):
|
||||
try:
|
||||
target_time = datetime.fromisoformat(target_time)
|
||||
except ValueError:
|
||||
return "时间格式错误"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
target_time = target_time.astimezone(timezone.utc)
|
||||
diff = now - target_time
|
||||
|
||||
# 如果是未来时间
|
||||
if diff.total_seconds() < 0:
|
||||
diff = -diff
|
||||
suffix = "后"
|
||||
else:
|
||||
suffix = "前"
|
||||
|
||||
seconds = diff.total_seconds()
|
||||
|
||||
# 定义时间间隔
|
||||
intervals = [
|
||||
(31536000, " 年"),
|
||||
(2592000, " 个月"),
|
||||
(86400, " 天"),
|
||||
(3600, " 小时"),
|
||||
(60, " 分钟"),
|
||||
(1, " 秒")
|
||||
]
|
||||
|
||||
# 计算最适合的时间单位
|
||||
for seconds_in_unit, unit in intervals:
|
||||
if seconds >= seconds_in_unit:
|
||||
value = int(seconds / seconds_in_unit)
|
||||
if unit == "个月" and value >= 12: # 超过12个月显示为年
|
||||
continue
|
||||
return f"{value}{unit}{suffix}"
|
||||
|
||||
return f"刚刚"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(format_phone("18888888888", private=True))
|
||||
print(format_phone("18888888888", private=False))
|
||||
Reference in New Issue
Block a user