V1.2.0 alpha 1
- 支持使用 api 提交物品信息 - 支持 JWT 鉴权
This commit is contained in:
2
routes/backend/__init__.py
Normal file
2
routes/backend/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import admin
|
||||
from . import session
|
||||
74
routes/backend/admin.py
Normal file
74
routes/backend/admin.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from nicegui import app
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException, status
|
||||
from jwt import InvalidTokenError
|
||||
import jwt, JWT
|
||||
from model import database
|
||||
from model import token as Token
|
||||
from model.response import DefaultResponse
|
||||
|
||||
async def is_admin(token: Annotated[str, Depends(JWT.oauth2_scheme)]):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Login required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, JWT.SECRET_KEY, algorithms=["HS256"])
|
||||
username = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except InvalidTokenError:
|
||||
raise credentials_exception
|
||||
if not username == await database.Database().get_setting('account'):
|
||||
raise credentials_exception
|
||||
token_data = Token.TokenData(username=username)
|
||||
return True
|
||||
|
||||
@app.get('/api/items')
|
||||
async def get_items(
|
||||
is_admin: Annotated[str, Depends(is_admin)],
|
||||
id: int = None,
|
||||
key: str = None):
|
||||
items = await database.Database().get_object(id=id, key=key)
|
||||
return DefaultResponse(data=items)
|
||||
|
||||
@app.post('/api/items')
|
||||
async def add_items(
|
||||
is_admin: Annotated[str, Depends(is_admin)],
|
||||
key: str,
|
||||
name: str,
|
||||
icon: str,
|
||||
phone: str):
|
||||
try:
|
||||
await database.Database().add_object(
|
||||
key=key, name=name, icon=icon, phone=phone)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
else:
|
||||
return DefaultResponse()
|
||||
|
||||
@app.patch('/api/items')
|
||||
async def update_items(
|
||||
is_admin: Annotated[str, Depends(is_admin)],
|
||||
id: int,
|
||||
**kwargs):
|
||||
try:
|
||||
await database.Database().update_object(
|
||||
id=id, **kwargs)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
else:
|
||||
return DefaultResponse()
|
||||
|
||||
@app.delete('/api/items')
|
||||
async def delete_items(
|
||||
is_admin: Annotated[str, Depends(is_admin)],
|
||||
id: int):
|
||||
try:
|
||||
await database.Database().delete_object(id=id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
else:
|
||||
return DefaultResponse()
|
||||
51
routes/backend/session.py
Normal file
51
routes/backend/session.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from nicegui import app
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi import APIRouter
|
||||
import jwt, JWT
|
||||
|
||||
from model.token import Token
|
||||
from model import database
|
||||
from tool import verify_password
|
||||
|
||||
Router = APIRouter()
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, JWT.SECRET_KEY, algorithm='HS256')
|
||||
return encoded_jwt
|
||||
|
||||
async def authenticate_user(username: str, password: str):
|
||||
# 验证账号和密码
|
||||
account = await database.Database().get_setting('account')
|
||||
stored_password = await database.Database().get_setting('password')
|
||||
|
||||
if account != username or not verify_password(stored_password, password):
|
||||
return False
|
||||
|
||||
return {'is_authenticated': True}
|
||||
|
||||
# FastAPI 登录路由 / FastAPI login route
|
||||
@app.post("/api/token")
|
||||
async def login_for_access_token(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
) -> Token:
|
||||
user = await authenticate_user(form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token_expires = timedelta(hours=1)
|
||||
access_token = create_access_token(
|
||||
data={"sub": form_data.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
366
routes/frontend/admin.py
Normal file
366
routes/frontend/admin.py
Normal file
@@ -0,0 +1,366 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-12-14 20:03:49
|
||||
FilePath: /Findreve/admin.py
|
||||
Description: Findreve 后台管理 admin
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
from nicegui import ui
|
||||
from typing import Dict
|
||||
import model
|
||||
import qrcode
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from fastapi import Request
|
||||
import model.database
|
||||
from tool import *
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def create():
|
||||
@ui.page('/admin')
|
||||
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 仪表盘").classes('text-lg').props('flat color=white no-caps')
|
||||
|
||||
siteDomain = request.base_url.hostname
|
||||
with ui.left_drawer() as left_drawer:
|
||||
with ui.column().classes('w-full'):
|
||||
ui.image('/static/Findreve.png').classes('w-1/2 mx-auto')
|
||||
with ui.row(align_items='center').classes('w-full'):
|
||||
ui.label('Findreve').classes('text-2xl text-bold')
|
||||
if siteDomain == "127.0.0.1" or siteDomain == "localhost":
|
||||
ui.label("本地模式无需授权").classes('text-gray-600 -mt-3')
|
||||
else:
|
||||
ui.label("免费版,无需授权").classes('text-red-600 -mt-3')
|
||||
|
||||
ui.button('首页 & 信息', icon='fingerprint', on_click=lambda: tabs.set_value('main_page')) \
|
||||
.classes('w-full').props('flat no-caps')
|
||||
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='main_page').classes('w-full').props('vertical'):
|
||||
# 站点一览
|
||||
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')
|
||||
|
||||
# 物品一览
|
||||
with ui.tab_panel('item'):
|
||||
|
||||
# 列表选择函数
|
||||
async def objectTableOnClick():
|
||||
try:
|
||||
status = str(object_table.selected[0]['status'])
|
||||
except:
|
||||
# 当物品列表未选中,显示添加物品按钮,其他按钮不显示
|
||||
addObjectFAB.set_visibility(True)
|
||||
lostObjectFAB.set_visibility(False)
|
||||
findObjectFAB.set_visibility(False)
|
||||
return
|
||||
|
||||
if status == "正常":
|
||||
# 选中正常物品,显示丢失按钮
|
||||
addObjectFAB.set_visibility(False)
|
||||
lostObjectFAB.set_visibility(True)
|
||||
findObjectFAB.set_visibility(False)
|
||||
elif status == "丢失":
|
||||
# 选中丢失物品,显示发现按钮
|
||||
addObjectFAB.set_visibility(False)
|
||||
lostObjectFAB.set_visibility(False)
|
||||
findObjectFAB.set_visibility(True)
|
||||
else:
|
||||
# 选中其他状态,隐藏所有按钮
|
||||
addObjectFAB.set_visibility(False)
|
||||
lostObjectFAB.set_visibility(False)
|
||||
findObjectFAB.set_visibility(False)
|
||||
|
||||
# 添加物品
|
||||
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.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 lostObject():
|
||||
try:
|
||||
# 获取选中物品
|
||||
object_id = object_table.selected[0]['id']
|
||||
await model.database.Database().update_object(id=object_id, status='lost')
|
||||
await model.database.Database().update_object(id=object_id, lost_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||
# 如果设置了留言,则更新留言
|
||||
if lostReason.value != "":
|
||||
await model.database.Database().update_object(id=object_id, context=lostReason.value)
|
||||
except Exception as e:
|
||||
ui.notify(str(e), color='negative')
|
||||
else:
|
||||
ui.notify('设置丢失成功', color='positive')
|
||||
# 刷新表格
|
||||
await reloadTable(tips=False)
|
||||
lostObjectDialog.close()
|
||||
# 将FAB设置为正常
|
||||
addObjectFAB.set_visibility(True)
|
||||
lostObjectFAB.set_visibility(False)
|
||||
findObjectFAB.set_visibility(False)
|
||||
|
||||
|
||||
# 设置物品丢失对话框
|
||||
with ui.dialog() as lostObjectDialog, ui.card().style('width: 90%; max-width: 500px'):
|
||||
ui.button(icon='gpp_bad', color='red').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
|
||||
ui.label('设置物品丢失').classes('w-full text-h5 text-center')
|
||||
|
||||
ui.label('确定要设置这个物品为丢失吗?')
|
||||
ui.html('设置为丢失以后,<b>你的电话号码将会被完整地显示在物品页面</b>(不是“*** **** 8888”而是“188 8888 8888”),以供拾到者能够记下你的电话号码。此外,在页面底部将会显示一个按钮,这个按钮能够一键拨打预先设置好的电话。')
|
||||
lostReason = ui.input('物主留言') \
|
||||
.classes('block w-full text-gray-900')
|
||||
lostReasonTips = ui.label('非必填,但建议填写,以方便拾到者联系你').classes('-mt-3')
|
||||
|
||||
async def handle_lost_object():
|
||||
await lostObject()
|
||||
|
||||
ui.button("确认提交", color='red', on_click=handle_lost_object) \
|
||||
.classes('items-center w-full').props('rounded')
|
||||
ui.button("返回", on_click=lostObjectDialog.close) \
|
||||
.classes('w-full').props('flat rounded')
|
||||
|
||||
async def findObject():
|
||||
try:
|
||||
object_id = object_table.selected[0]['id']
|
||||
await model.database.Database().update_object(id=object_id, status='ok')
|
||||
await model.database.Database().update_object(id=object_id, context=None)
|
||||
await model.database.Database().update_object(id=object_id, find_ip=None)
|
||||
await model.database.Database().update_object(id=object_id, lost_at=None)
|
||||
except Exception as e:
|
||||
ui.notify(str(e), color='negative')
|
||||
else:
|
||||
ui.notify('解除丢失成功', color='positive')
|
||||
# 刷新表格
|
||||
await reloadTable(tips=False)
|
||||
findObjectDialog.close()
|
||||
# 将FAB设置为正常
|
||||
addObjectFAB.set_visibility(True)
|
||||
lostObjectFAB.set_visibility(False)
|
||||
findObjectFAB.set_visibility(False)
|
||||
|
||||
# 解除丢失对话框
|
||||
with ui.dialog() as findObjectDialog, ui.card().style('width: 90%; max-width: 500px'):
|
||||
ui.button(icon='remove_moderator').props('outline round').classes('mx-auto w-auto shadow-sm w-fill')
|
||||
ui.label('解除丢失').classes('w-full text-h5 text-center')
|
||||
|
||||
ui.label('确定物品已经找回了吗?')
|
||||
|
||||
async def handle_find_object():
|
||||
await findObject()
|
||||
|
||||
ui.button("确认提交", on_click=handle_find_object) \
|
||||
.classes('items-center w-full').props('rounded')
|
||||
ui.button("返回") \
|
||||
.classes('w-full').props('flat rounded')
|
||||
|
||||
async def fetch_and_process_objects():
|
||||
# 获取所有物品
|
||||
objects = [dict(zip(['id', 'key', 'name', 'icon', 'status', 'phone', 'context',
|
||||
'find_ip', 'create_at', 'lost_at'], obj)) for obj in await model.database.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'])
|
||||
return objects
|
||||
|
||||
async def reloadTable(tips: bool = True):
|
||||
objects = await fetch_and_process_objects()
|
||||
object_table.update_rows(objects)
|
||||
if tips:
|
||||
ui.notify('刷新成功')
|
||||
|
||||
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': 'status', 'label': '物品状态', 'field': 'status', 'required': True, 'align': 'left'},
|
||||
{'name': 'phone', 'label': '物品绑定手机', 'field': 'phone', 'required': True, 'align': 'left'},
|
||||
{'name': 'context', 'label': '丢失描述', 'field': 'context', 'required': True, 'align': 'left'},
|
||||
{'name': 'find_ip', 'label': '物品发现IP', 'field': 'find_ip', 'required': True, 'align': 'left'},
|
||||
{'name': 'create_at', 'label': '物品创建时间', 'field': 'create_at', 'required': True, 'align': 'left'},
|
||||
{'name': 'lost_at', 'label': '物品丢失时间', 'field': 'lost_at', 'required': True, 'align': 'left'}
|
||||
]
|
||||
|
||||
objects = await fetch_and_process_objects()
|
||||
object_table = ui.table(
|
||||
title='物品 & 库存',
|
||||
row_key='id',
|
||||
pagination=10,
|
||||
selection='single',
|
||||
columns=object_columns,
|
||||
rows=objects,
|
||||
on_select=lambda: objectTableOnClick()
|
||||
).classes('w-full').props('flat')
|
||||
|
||||
object_table.add_slot('body-cell-status', '''
|
||||
<q-td key="status" :props="props">
|
||||
<q-badge :color="props.value === '正常' ? 'green' : 'red'">
|
||||
{{ props.value }}
|
||||
</q-badge>
|
||||
</q-td>
|
||||
''')
|
||||
|
||||
|
||||
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')
|
||||
with ui.page_sticky(x_offset=24, y_offset=24) as lostObjectFAB:
|
||||
ui.button(icon='gpp_bad', color='red', on_click=lostObjectDialog.open) \
|
||||
.props('fab')
|
||||
# 单独拉出来默认隐藏,防止无法再设置其显示
|
||||
lostObjectFAB.set_visibility(False)
|
||||
with ui.page_sticky(x_offset=24, y_offset=24) as findObjectFAB:
|
||||
ui.button(icon='remove_moderator', on_click=findObjectDialog.open) \
|
||||
.props('fab')
|
||||
# 单独拉出来默认隐藏,防止无法再设置其显示
|
||||
findObjectFAB.set_visibility(False)
|
||||
|
||||
# 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')
|
||||
133
routes/frontend/found.py
Normal file
133
routes/frontend/found.py
Normal file
@@ -0,0 +1,133 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:03:58
|
||||
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.database import Database
|
||||
from tool import format_phone
|
||||
|
||||
def create() -> None:
|
||||
@ui.page('/found')
|
||||
async def found_page(request: Request, 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')
|
||||
|
||||
elif object_data[4] == 'ok':
|
||||
# 物品存在, 但未标记为丢失
|
||||
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('This item has not been marked as lost. 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')
|
||||
|
||||
elif object_data[4] == 'lost':
|
||||
# 物品存在, 且标记为丢失
|
||||
with ui.card().classes('absolute-center w-3/4 h-3/4'):
|
||||
|
||||
# 添加标题
|
||||
ui.button(icon=object_data[3], color='red').props('outline round').classes('mx-auto w-auto shadow-sm w-fill max-md:hidden')
|
||||
with ui.label('关于此 '+ object_data[2]).classes('text-h5 w-full text-center'):
|
||||
ui.badge('已被标记为丢失 Already lost', color='red').classes('text-lg -right-10').props('floating')
|
||||
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 -mt-1')
|
||||
|
||||
ui.label('物主(Owner):'+ format_phone(object_data[5], private=False)).classes('text-md w-full text-center -mt-3')
|
||||
|
||||
ui.label('丢失时间(Lost time):'+ object_data[9]).classes('text-md w-full text-center -mt-3')
|
||||
|
||||
ui.space()
|
||||
|
||||
try:
|
||||
ui.label('物主留言(Owner message):'+ object_data[6]) \
|
||||
.classes('text-md w-full text-center')
|
||||
except: pass
|
||||
|
||||
ui.space()
|
||||
|
||||
ui.label('此物品已被物主标记为丢失。您可以通过上面的电话号码来联系物主。').classes('text-md w-full text-center')
|
||||
ui.label('This item has been marked as lost by the owner. You can contact the owner through the phone number above.').classes('text-xs w-full text-center text-gray-500 -mt-3')
|
||||
|
||||
ui.button('联系物主',
|
||||
on_click=lambda: ui.navigate.to('tel:' + object_data[5])) \
|
||||
.classes('items-center w-full').props('rounded')
|
||||
|
||||
await db.update_object(id=object_data[0], find_ip=str(request.client.host))
|
||||
|
||||
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('The item status information has been lost. If you have found this item, please contact the owner as soon as possible. If you are the owner, please modify the item status information.').classes('text-xs w-full text-center text-gray-500 -mt-3')
|
||||
|
||||
|
||||
loading.close()
|
||||
loading.clear()
|
||||
return
|
||||
29
routes/frontend/framework.py
Normal file
29
routes/frontend/framework.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from contextlib import contextmanager
|
||||
from nicegui import ui
|
||||
from fastapi import Request
|
||||
|
||||
@contextmanager
|
||||
def frame(request: Request):
|
||||
with ui.header() \
|
||||
.classes('items-center duration-300 py-2 px-5 no-wrap') \
|
||||
.style('box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1)'):
|
||||
with ui.tabs(value='main_page') as tabs:
|
||||
ui.button(icon='menu', on_click=lambda: left_drawer.toggle()).props('flat color=white round')
|
||||
ui.button(text="Findreve 仪表盘").classes('text-lg').props('flat color=white no-caps')
|
||||
|
||||
with ui.left_drawer() as left_drawer:
|
||||
with ui.column().classes('w-full'):
|
||||
ui.image('/static/Findreve.png').classes('w-1/2 mx-auto')
|
||||
ui.label('Findreve').classes('text-2xl text-bold')
|
||||
ui.label("免费版,无需授权").classes('text-red-600 -mt-3')
|
||||
|
||||
ui.button('首页 & 信息', icon='fingerprint', on_click=lambda: ui.navigate.to('/admin/home')) \
|
||||
.classes('w-full').props('flat no-caps')
|
||||
ui.button('物品 & 库存', icon='settings', on_click=lambda: ui.navigate.to('/admin/item')) \
|
||||
.classes('w-full').props('flat no-caps')
|
||||
ui.button('产品 & 授权', icon='settings', on_click=lambda: ui.navigate.to('/admin/auth')) \
|
||||
.classes('w-full').props('flat no-caps')
|
||||
ui.button('关于 & 反馈', icon='settings', on_click=lambda: ui.navigate.to('/admin/about')) \
|
||||
.classes('w-full').props('flat no-caps')
|
||||
|
||||
yield
|
||||
95
routes/frontend/login.py
Normal file
95
routes/frontend/login.py
Normal file
@@ -0,0 +1,95 @@
|
||||
'''
|
||||
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
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi import Request
|
||||
|
||||
def create() -> Optional[RedirectResponse]:
|
||||
@ui.page('/login')
|
||||
async def session(request: Request, redirect_to: str = "/"):
|
||||
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>
|
||||
""")
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
result = await ui.run_javascript(f"login('{username}', '{password}')")
|
||||
if result['status'] == 'success':
|
||||
ui.navigate.to(redirect_to)
|
||||
else:
|
||||
ui.notify("账号或密码错误", type="negative")
|
||||
except Exception as e:
|
||||
ui.notify(f"登录失败: {str(e)}", type="negative")
|
||||
|
||||
|
||||
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").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('登录 Findreve').classes('text-h5 w-full text-center')
|
||||
# 用户名/密码框
|
||||
username = ui.input('账号').on('keydown.enter', try_login) \
|
||||
.classes('block w-full text-gray-900').props('filled')
|
||||
password = ui.input('密码', password=True, password_toggle_button=True) \
|
||||
.on('keydown.enter', try_login).classes('block w-full text-gray-900').props('filled')
|
||||
|
||||
# 按钮布局
|
||||
ui.button('登录', on_click=lambda: login()).classes('items-center w-full').props('rounded')
|
||||
148
routes/frontend/main_page.py
Normal file
148
routes/frontend/main_page.py
Normal file
@@ -0,0 +1,148 @@
|
||||
'''
|
||||
Author: 于小丘 海枫
|
||||
Date: 2024-10-02 15:23:34
|
||||
LastEditors: Yuerchu admin@yuxiaoqiu.cn
|
||||
LastEditTime: 2024-11-29 20:04:24
|
||||
FilePath: /Findreve/main_page.py
|
||||
Description: Findreve 个人主页 main_page
|
||||
|
||||
Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved.
|
||||
'''
|
||||
|
||||
from nicegui import ui
|
||||
from fastapi import Request
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
import model
|
||||
|
||||
|
||||
def create() -> None:
|
||||
@ui.page('/')
|
||||
async def main_page(request: Request) -> None:
|
||||
|
||||
dark_mode = ui.dark_mode(value=True)
|
||||
|
||||
with ui.row(align_items='center').classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl p-24'):
|
||||
with ui.column(align_items='center').classes('px-2 max-md:hidden'):
|
||||
ui.chip('🐍 Python 是最好的语言').classes('text-xs -mt-1 -right-3').props('floating outline')
|
||||
ui.chip('🎹 精通 FL Studio Mobile').classes('text-xs -mt-1').props('floating outline')
|
||||
ui.chip('🎨 熟悉 Ps/Pr/Ae/Au/Ai').classes('text-xs -mt-1').props('floating outline')
|
||||
ui.chip('🏎 热爱竞速(如地平线5)').classes('text-xs -mt-1 -right-3').props('floating outline')
|
||||
with ui.avatar().classes('w-32 h-32 transition-transform duration-300 hover:scale-110 cursor-pointer'):
|
||||
# 下面的这个是Base64格式,你需要改成你自己的头像,支持链接,也可以用Base64本地化
|
||||
ui.image('/static/heyfun.jpg').classes('w-32 h-32')
|
||||
with ui.column().classes('px-2 max-md:hidden'):
|
||||
ui.chip('喜欢去广州图书馆看书 📕').classes('text-xs -mt-1 -left-3').props('floating outline')
|
||||
ui.chip('致力做安卓苹果开发者 📱').classes('text-xs -mt-1').props('floating outline')
|
||||
ui.chip('正在自研全链个人生态 🔧').classes('text-xs -mt-1').props('floating outline')
|
||||
ui.chip('致力与开源社区同发展 🤝').classes('text-xs -mt-1 -left-3').props('floating outline')
|
||||
|
||||
ui.label('关于本站').classes('w-full text-4xl text-bold text-center py-6 subpixel-antialiased')
|
||||
|
||||
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl py-4'):
|
||||
with ui.card().classes('w-full sm:w-1/5 lg:w-1/7 flex-grow p-8 bg-gradient-to-br from-indigo-700 to-blue-500'):
|
||||
ui.label('你好,很高兴认识你👋').classes('text-md text-white')
|
||||
with ui.row(align_items='center'):
|
||||
ui.label('我叫').classes('text-4xl text-bold text-white -mt-1 subpixel-antialiased')
|
||||
ui.label('于小丘').classes('text-4xl text-bold text-white -mt-1 subpixel-antialiased').tooltip('英文名叫Yuerchu,也可以叫我海枫')
|
||||
ui.label('是一名 开发者、音乐人').classes('text-md text-white -mt-1')
|
||||
with ui.card().classes('w-full sm:w-1/2 lg:w-1/4 flex-grow flex flex-col justify-center'):
|
||||
ui.code('void main() {\n printf("为了尚未完成的未来");\n}', language='c').classes('text-3xl max-[768px]:text-xl text-bold text-white flex-grow w-full h-full')
|
||||
|
||||
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl -mt-3'):
|
||||
with ui.card().classes('w-full sm:w-1/2 lg:w-1/4 flex-grow p-4'):
|
||||
ui.label('技能').classes('text-md text-gray-500')
|
||||
ui.label('开启创造力').classes('text-4xl text-bold -mt-1 right-4')
|
||||
|
||||
with ui.row().classes('items-center'):
|
||||
ui.chip('Python', color='amber-400').classes('p-4').props('floating').tooltip('Python是世界上最好的语言')
|
||||
ui.chip('Kotlin', color='violet-400').classes('p-4').props('floating').tooltip('Kotlin给安卓开发APP')
|
||||
ui.chip('Golang', color='sky-400').classes('p-4').props('floating').tooltip('Golang写后端')
|
||||
ui.chip('Lua', color='blue-900').classes('p-4').props('floating').tooltip('用aLua给安卓开发,给罗技鼠标写鼠标宏')
|
||||
ui.chip('c', color='red-400').classes('p-4').props('floating').tooltip('C写嵌入式开发')
|
||||
ui.chip('FL Studio', color='orange-600').classes('p-4').props('floating').tooltip('FL Studio是世界上最好的宿主')
|
||||
ui.chip('Photoshop', color='blue-950').classes('p-4').props('floating').tooltip('修图/抠图/画画一站通')
|
||||
ui.chip('Premiere', color='indigo-900').classes('p-4').props('floating').tooltip('剪视频比较顺手,但是一开风扇狂转')
|
||||
ui.chip('After Effects', color='indigo-950').classes('p-4').props('floating').tooltip('制作特效,电脑太烂了做不了太花的')
|
||||
ui.chip('Audition', color='purple-900').classes('p-4').props('floating').tooltip('写歌做母带挺好用的')
|
||||
ui.chip('Illustrator', color='amber-800').classes('p-4').props('floating').tooltip('自制字体和画动态SVG')
|
||||
ui.chip('HTML', color='red-900').classes('p-4').props('floating').tooltip('前端入门三件套,不学这玩意其他学了没用')
|
||||
ui.chip('CSS3', color='cyan-900').classes('p-4').props('floating').tooltip('. window{ show: none; }')
|
||||
ui.chip('JavaScript', color='lime-900').classes('p-4').props('floating').tooltip('还在努力学习中,只会一些简单的')
|
||||
ui.chip('git', color='amber-700').classes('p-4').props('floating').tooltip('版本管理是真好用')
|
||||
ui.chip('Docker', color='sky-600').classes('p-4').props('floating').tooltip('容器化部署')
|
||||
ui.chip('chatGPT', color='emerald-600').classes('p-4').props('floating').tooltip('文本助驾,写代码/写文章/写论文')
|
||||
ui.chip('SAI2', color='gray-950').classes('p-4').props('floating').tooltip('入门绘画')
|
||||
ui.chip('ips Draw', color='gray-900').classes('p-4').props('floating').tooltip('自认为是iOS端最佳绘画软件')
|
||||
ui.chip('AutoCAD', color='gray-950').classes('p-4').props('floating').tooltip('画图/绘制电路图')
|
||||
ui.chip('SolidWorks', color='gray-900').classes('p-4').props('floating').tooltip('画图/绘制3D模型')
|
||||
ui.chip('EasyEDA', color='gray-950').classes('p-4').props('floating').tooltip('画图/绘制电路图')
|
||||
ui.chip('KiCad', color='gray-900').classes('p-4').props('floating').tooltip('画图/绘制电路图')
|
||||
ui.chip('Altium Designer', color='gray-950').classes('p-4').props('floating').tooltip('画图/绘制电路图')
|
||||
ui.label('...').classes('text-md text-gray-500')
|
||||
with ui.card().classes('w-full sm:w-1/3 lg:w-1/6 flex-grow flex flex-col justify-center'):
|
||||
ui.label('生涯').classes('text-md text-gray-500')
|
||||
ui.label('无限进步').classes('text-4xl text-bold -mt-1 right-4')
|
||||
|
||||
with ui.timeline(side='right', layout='comfortable'):
|
||||
ui.timeline_entry('那天,我买了第一台服务器,并搭建了我第一个Wordpress站点',
|
||||
title='梦开始的地方',
|
||||
subtitle='2022年1月21日')
|
||||
ui.timeline_entry('准备从Cloudreve项目脱离,自建网盘系统DiskNext',
|
||||
title='自建生态计划开始',
|
||||
subtitle='2024年3月1日')
|
||||
ui.timeline_entry('目前正在开发HeyAuth、Findreve、DiskNext',
|
||||
title='项目框架仍在研发中',
|
||||
subtitle='现在',
|
||||
icon='rocket')
|
||||
|
||||
|
||||
|
||||
ui.label('我的作品').classes('w-full text-center text-2xl text-bold p-4')
|
||||
|
||||
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl'):
|
||||
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||
with ui.row().classes('items-center w-full -mt-2'):
|
||||
ui.label('DiskNext').classes('text-lg text-bold')
|
||||
ui.chip('B端程序').classes('text-xs').props('floating')
|
||||
ui.space()
|
||||
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://pan.yxqi.cn'))).props('flat fab-mini')
|
||||
ui.label('一个基于NiceGUI的网盘系统,性能与Golang媲美').classes('text-sm -mt-3')
|
||||
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||
with ui.row().classes('items-center w-full -mt-2'):
|
||||
ui.label('Findreve').classes('text-lg text-bold')
|
||||
ui.chip('C端程序').classes('text-xs').props('floating')
|
||||
ui.space()
|
||||
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://i.yxqi.cn'))).props('flat fab-mini')
|
||||
ui.label('一个基于NiceGUI的个人主页配合物品丢失找回系统').classes('text-sm -mt-3')
|
||||
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||
with ui.row().classes('items-center w-full -mt-2'):
|
||||
ui.label('HeyAuth').classes('text-lg text-bold')
|
||||
ui.chip('B端程序').classes('text-xs').props('floating')
|
||||
ui.space()
|
||||
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://auth.yxqi.cn'))).props('flat fab-mini')
|
||||
ui.label('一个基于NiceGUI的B+C端多应用授权系统').classes('text-sm -mt-3')
|
||||
|
||||
with ui.row().classes('w-full items-center justify-center items-stretch mx-auto mx-8 max-w-7xl'):
|
||||
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||
with ui.row().classes('items-center w-full -mt-2'):
|
||||
ui.label('与枫同奔 Run With Fun').classes('text-lg text-bold')
|
||||
ui.chip('词曲').classes('text-xs').props('floating')
|
||||
ui.space()
|
||||
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=2148944359'))).props('flat fab-mini')
|
||||
ui.label('我愿如流星赶月那样飞奔').classes('text-sm -mt-3')
|
||||
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||
with ui.row().classes('items-center w-full -mt-2'):
|
||||
ui.label('HeyFun\'s Story').classes('text-lg text-bold')
|
||||
ui.chip('自设印象曲').classes('text-xs').props('floating')
|
||||
ui.space()
|
||||
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=1889436124'))).props('flat fab-mini')
|
||||
ui.label('飞奔在星辰大海之间的少年').classes('text-sm -mt-3')
|
||||
with ui.card().classes('w-full sm:w-1/3 lg:w-1/5 flex-grow p-4'):
|
||||
with ui.row().classes('items-center w-full -mt-2'):
|
||||
ui.label('2020Fall').classes('text-lg text-bold')
|
||||
ui.chip('年度纯音乐').classes('text-xs').props('floating')
|
||||
ui.space()
|
||||
ui.button(icon='open_in_new', on_click=lambda: (ui.navigate.to('https://music.163.com/#/song?id=1863630345'))).props('flat fab-mini')
|
||||
ui.label('耗时6个月完成的年度纯音乐').classes('text-sm -mt-3')
|
||||
Reference in New Issue
Block a user