This commit is contained in:
liubiren 2026-07-16 20:07:22 +08:00
parent cd08996e16
commit bd1e5912a5
10 changed files with 412 additions and 210 deletions

View File

@ -20,18 +20,22 @@ app = rx.App(
"*::before, *::after": { "*::before, *::after": {
"box_sizing": "border-box", "box_sizing": "border-box",
}, },
"--prismui-background-color-1": "#ffffff", "--prismui-background-color-1": "#ffffff", # 纯白背景色
"--prismui-background-color-2": "rgb(235, 235, 235)", "--prismui-background-color-2": "rgb(235, 235, 235)",
"--prismui-background-color-3": "#f6f6f8", "--prismui-background-color-3": "#f6f6f8",
"--prismui-background-color-4": "#babbc0",
"--prismui-background-1": "linear-gradient(to bottom, #D0C9FF 0%, #E6D6F0 8%, #F1DBEA 12%, #C8DCFB 40%, #ABC6F6 60%, #87AEFE 90%)",
"--mc-float-block-shadow": "rgba(213, 213, 213, 0.25)", "--mc-float-block-shadow": "rgba(213, 213, 213, 0.25)",
"--devui-global-bg": "#f6f6f8", "--devui-global-bg": "#f6f6f8",
"--mc-global-bg": "linear-gradient(to bottom, #D0C9FF 0%, #E6D6F0 8%, #F1DBEA 12%, #C8DCFB 40%, #ABC6F6 60%, #87AEFE 90%)", "--mc-global-bg": "linear-gradient(to bottom, #D0C9FF 0%, #E6D6F0 8%, #F1DBEA 12%, #C8DCFB 40%, #ABC6F6 60%, #87AEFE 90%)",
"--mc-box-shadow": "rgba(25, 25, 25, .06)", "--mc-box-shadow": "rgba(25, 25, 25, .06)",
"--devui-shadow-length-connected-overlay": "0px 0px 12px 0px rgba(0, 0, 0, 0.1)", "--devui-shadow-length-connected-overlay": "0px 0px 12px 0px rgba(0, 0, 0, 0.1)",
"--prismui-box-shadow-1": "0 4px 12px #0000001a", # 导航按钮阴影
"--devui-icon-fill-weak": "#babbc0", "--devui-icon-fill-weak": "#babbc0",
"--prismui-background-color-weak": "#babbc0", "--prismui-background-color-weak": "#babbc0",
"--devui-border-radius-feedback": "4px", "--devui-border-radius-feedback": "4px",
"--prismui-border-radius": "8px", "--prismui-border-radius": "8px",
"--prismui-border-radius-1": "8px",
"--devui-border-radius-full": "100px", "--devui-border-radius-full": "100px",
"--font-family": "HuaweiFont,Helvetica,Arial,PingFangSC-Regular,Hiragino Sans GB,Microsoft YaHei,微软雅黑,Microsoft JhengHei", "--font-family": "HuaweiFont,Helvetica,Arial,PingFangSC-Regular,Hiragino Sans GB,Microsoft YaHei,微软雅黑,Microsoft JhengHei",
"--prismui-font-size": "14px", "--prismui-font-size": "14px",
@ -43,7 +47,7 @@ app = rx.App(
"--devui-font-size-lg": "14px", "--devui-font-size-lg": "14px",
"--prismui-color-text": "#252b3a", "--prismui-color-text": "#252b3a",
"--prismui-color-text-light": "#babbc0", "--prismui-color-text-light": "#babbc0",
"--prismui-color-1": "#252b3a", "--prismui-color-1": "#252b3a", # 文本颜色
"--prismui-color-2": "#575d6c", "--prismui-color-2": "#575d6c",
"--prismui-color-3": "#babbc0", "--prismui-color-3": "#babbc0",
"--devui-text-weak": "#575d6c", "--devui-text-weak": "#575d6c",

View File

@ -1,120 +0,0 @@
# -*- coding: utf-8 -*-
"""
渲染框架相关组件
"""
import reflex as rx
from application.states import SidebarState
# 侧边栏图标导航按钮字典
buttons = {
"conversation": {"src": "/conversation.svg", "text": "会话"},
"library": {"src": "/library.svg", "text": "知识库"},
}
def sidebar() -> rx.Component:
"""
侧边栏
"""
def button(
button: str,
) -> rx.Component:
"""
侧边栏图标导航按钮
:param button: 指定侧边栏图标导航按钮
"""
# 指定按钮的激活状态
is_actived = button == SidebarState.get_activated_button
return rx.box(
# 渲染图标容器
rx.box(
# 渲染图标
rx.image(
src=buttons[button]["src"],
width="24px",
height="24px",
object_fit="contain",
),
display="flex",
justify_content="center",
align_items="center",
width="36px",
height="36px",
border_radius="var(--prismui-border-radius)",
# 若激活则渲染背景颜色和阴影,否则不渲染
style={
"background-color": rx.cond(
is_actived, "var(--prismui-background-color)", "transparent"
),
"box-shadow": rx.cond(is_actived, "0 4px 12px #0000001a", "none"),
},
),
# 渲染标签
rx.text(
buttons[button]["text"],
color="var(--prismui-color-text)",
font_size="var(--prismui-font-size-sm)",
line_height="20px",
),
display="flex",
flex_direction="column",
align_items="center",
gap="4px",
cursor="pointer",
# 点击事件:将指定图标导航按钮设置为侧边栏中激活的图标导航按钮
on_click=SidebarState.switch_activated_button(button),
)
return rx.vstack(
rx.vstack(
rx.vstack(
# 品牌图标
rx.image(
src="/logo.png",
width="34px",
height="34px",
object_fit="contain",
),
# 品牌名称
rx.text(
"棱镜球",
line_height="20px",
font_size="11px",
font_weight="700",
color="var(--prismui-color-text)",
),
display="flex",
flex_direction="column",
justify_content="center",
align_items="center",
gap="4px",
),
# 渲染分隔线
rx.divider(
width="32px",
height="1px",
margin="16px 0",
background_color="var(--prismui-background-color-weak)",
),
# 渲染侧边栏中会话图标导航按钮
button("conversation"),
# 渲染侧边栏中知识库图标导航按钮
button("library"),
display="flex",
flex_direction="column",
align_items="center",
width="100%",
margin_top="12px",
),
display="flex",
flex_direction="column",
justify_content="space-between",
align_items="center",
width="60px",
height="100%",
box_sizing="border-box",
)

View File

@ -6,7 +6,7 @@ import reflex as rx
from application.models import Conversation, Dialog, ThoughtNode from application.models import Conversation, Dialog, ThoughtNode
from application.states import ( from application.states import (
ConversationHistoryState, ConversationHistoryCollapseState,
ConversationState, ConversationState,
CreateConversationState, CreateConversationState,
) )
@ -323,6 +323,7 @@ def render_thought_node(thought_node_id: int, thought_node: ThoughtNode):
"thinking", "thinking",
rx.text( rx.text(
thought_node.content, thought_node.content,
line_height="22px",
font_size="var(--prismui-font-size-2)", font_size="var(--prismui-font-size-2)",
color="var(--prismui-color-3)", color="var(--prismui-color-3)",
), ),
@ -426,27 +427,28 @@ def render_dialog_history_item(dialog_id: str, dialog: Dialog) -> rx.Component:
on_click=lambda: ConversationState.toggle_collapse(dialog_id), on_click=lambda: ConversationState.toggle_collapse(dialog_id),
), ),
rx.box( rx.box(
rx.auto_scroll( rx.box(
rx.foreach( rx.auto_scroll(
thought_nodes, rx.foreach(
lambda i, _: render_thought_node(i[0], i[1]), thought_nodes,
lambda i, _: render_thought_node(i[0], i[1]),
),
width="100%",
margin_top="8px",
align_self="start",
), ),
width="100%", style={"overflow": "hidden", "min-height": "0"},
margin_top="8px",
align_self="start",
), ),
width="100%", width="100%",
overflow="hidden",
style={ style={
"max-height": rx.cond(is_collapse_expanded, "1200px", "20px"), "display": "grid",
"overflow": "hidden",
"grid_template_rows": rx.cond(is_collapse_expanded, "1fr", "0fr"),
"opacity": rx.cond(is_collapse_expanded, "1", "0"), "opacity": rx.cond(is_collapse_expanded, "1", "0"),
"transition": "max-height 0.2s ease-out, opacity 0.2s ease-out", # 双属性同步过渡,和千问一致
"transition": "grid-template-rows 0.3s ease-out, opacity 0.3s ease-out",
}, },
), ),
width="100%",
margin_top="8px",
text_align="left",
overflow="visible",
), ),
# 渲染回答 # 渲染回答
rx.markdown( rx.markdown(
@ -744,16 +746,16 @@ def render_conversation_workplace() -> rx.Component:
def render_conversation_history_collapse_button( def render_conversation_history_collapse_button(
is_collapse_expanded: bool, is_expanded: bool,
) -> rx.Component: ) -> rx.Component:
""" """
渲染会话历史折叠面板的打开/关闭按钮 渲染会话历史折叠面板的打开/关闭按钮
:param is_collapse_expanded: 会话历史折叠面板展开状态 :param is_expanded: 会话历史折叠面板展开状态
:return: Component :return: Component
""" """
return rx.button( return rx.button(
rx.cond( rx.cond(
is_collapse_expanded, is_expanded,
rx.icon( rx.icon(
"chevron-left", "chevron-left",
width="16px", width="16px",
@ -769,17 +771,17 @@ def render_conversation_history_collapse_button(
), ),
position="absolute", position="absolute",
top="50%", top="50%",
left=rx.cond(is_collapse_expanded, "calc(clamp(240px, 25%, 380px) - 8px)", "0"), left=rx.cond(is_expanded, "calc(clamp(240px, 25%, 380px) - 8px)", "0"),
z_index=99, z_index=99,
width="16px", width="16px",
height="40px", height="40px",
background="var(--prismui-background-color-1)", background="var(--prismui-background-color-1)",
box_shadow="2px 0 4px 0 var(--mc-float-block-shadow)", box_shadow="2px 0 4px 0 var(--mc-float-block-shadow)",
border_radius=rx.cond(is_collapse_expanded, "6px", "0 6px 6px 0"), border_radius=rx.cond(is_expanded, "6px", "0 6px 6px 0"),
transition="all 0.3s ease-in-out", transition="all 0.3s ease-in-out",
cursor="pointer", cursor="pointer",
# 点击事件:打开/关闭对话历史折叠面板 # 点击事件:切换会话历史折叠面板展开状态
on_click=ConversationHistoryState.toggle_collapse, on_click=ConversationHistoryCollapseState.toggle_expanded,
) )
@ -787,14 +789,14 @@ def render_conversation() -> rx.Component:
""" """
渲染会话页面布局参考 MetaChat左侧为会话历史右侧为对话历史和输入框 渲染会话页面布局参考 MetaChat左侧为会话历史右侧为对话历史和输入框
""" """
# 会话历史折叠面板展开状态 # 会话历史折叠面板展开状态
is_collapse_expanded = ConversationHistoryState.is_collapse_expanded is_expanded = ConversationHistoryCollapseState.is_expanded
return rx.box( return rx.box(
# 渲染对话历史折叠面板 # 渲染折叠面板
rx.hstack( rx.hstack(
# 渲染对话历史折叠面板 # 渲染折叠面板
render_conversation_history_collapse(is_collapse_expanded), render_conversation_history_collapse(is_expanded),
# 渲染会话工作区 # 渲染会话工作区
render_conversation_workplace(), render_conversation_workplace(),
position="relative", position="relative",
@ -806,7 +808,7 @@ def render_conversation() -> rx.Component:
transition="all 0.3s ease-in-out", transition="all 0.3s ease-in-out",
), ),
# 渲染对话历史折叠面板打开/关闭按钮 # 渲染对话历史折叠面板打开/关闭按钮
render_conversation_history_collapse_button(is_collapse_expanded), render_conversation_history_collapse_button(is_expanded),
position="relative", position="relative",
width="100%", width="100%",
height="100%", height="100%",

View File

@ -4,34 +4,250 @@
""" """
import reflex as rx import reflex as rx
from application.components import sidebar
from application.pages import render_conversation, render_library from application.pages import render_conversation, render_library
from application.pages.conversation import render_conversation from application.pages.conversation import render_conversation
from application.states import AuthState, SidebarState from application.states import AuthState, SidebarState
# 导航按钮字典
nav_buttons: dict = {
"conversation": {"src": "/conversation.svg", "text": "会话"},
"library": {"src": "/library.svg", "text": "知识库"},
}
"""
约定
1 若组件仅用于当前页面则无需抽象为通用组件(application.components.*)
"""
def render_nav_button(
nav_button: str,
) -> rx.Component:
"""
渲染导航按钮
:param nav_button: 指定的导航按钮
:return : Component
"""
# 导航按钮激活状态
is_actived = nav_button == SidebarState.activated_nav_button
return rx.vstack(
# 渲染图标
rx.image(
nav_buttons[nav_button]["src"],
width="36px",
height="36px",
object_fit="contain",
padding="6px",
background_color=rx.cond(
is_actived, "var(--prismui-background-color-1)", "transparent"
),
border_radius="var(--prismui-border-radius-1)",
box_shadow=rx.cond(is_actived, "var(--prismui-box-shadow-1)", "none"),
),
# 渲染标签
rx.text(
nav_buttons[nav_button]["text"],
line_height="20px",
color="var(--prismui-color-1)",
font_size="var(--prismui-font-size-2)",
),
align_items="center",
gap="4px",
cursor="pointer",
# 点击事件:将指定导航按钮设置为侧边栏中激活的导航按钮
on_click=SidebarState.switch_nav_button(nav_button),
)
def render_sidebar() -> rx.Component:
"""
渲染侧边栏
:return: Component
"""
return rx.vstack(
rx.vstack(
rx.vstack(
# 品牌图标
rx.image(
src="/logo.png",
width="34px",
height="34px",
object_fit="contain",
),
# 品牌名称
rx.text(
"棱镜球",
line_height="20px",
font_size="11px",
font_weight="var(--prismui-font-weight-1)",
color="var(--prismui-color-1)",
),
align_items="center",
gap="4px",
),
# 渲染分隔线
rx.divider(
width="32px",
height="1px",
margin="16px 0",
background_color="var(--prismui-background-color-4)",
),
rx.vstack(
# 渲染会话导航按钮
render_nav_button("conversation"),
# 渲染知识库导航按钮
render_nav_button("library"),
gap="8px",
),
align_items="center",
width="100%",
margin_top="12px",
),
flex_shrink="0",
justify_content="space-between",
align_items="center",
width="60px",
min_width="60px",
max_width="60px",
height="100%",
)
def render_login_modal() -> rx.Component:
"""
渲染登录模态窗
:return: Component
"""
return rx.center(
rx.hstack(
rx.vstack(
rx.text(
"登录",
font_size="20px",
font_weight="600",
margin_bottom="24px",
),
rx.input(
placeholder="请输入邮箱地址",
value=AuthState.email,
on_change=AuthState.set_email,
width="100%",
),
rx.hstack(
rx.input(
placeholder="请输入验证码",
value=AuthState.captcha,
on_change=AuthState.set_captcha,
flex=1,
),
rx.button(
"获取验证码",
variant="outline",
width="120px",
on_click=AuthState.send_email_code,
),
gap="8px",
width="100%",
),
rx.cond(
AuthState.email == "",
rx.text("请输入邮箱地址", color="red", font_size="12px"),
rx.box(height="16px"),
),
rx.hstack(
rx.checkbox(
checked=AuthState.agree_policy,
on_change=AuthState.toggle_agree,
),
rx.text("我已阅读并同意 ", font_size="13px"),
rx.link("用户协议、隐私政策", font_size="13px", href="#"),
align_items="center",
),
rx.button(
"登录",
width="100%",
bg="#0a0e29",
color="white",
margin_top="12px",
on_click=AuthState.login_by_email_code,
),
rx.text(
"登录有问题?去反馈",
font_size="12px",
color="#666",
margin_top="8px",
),
width="360px",
gap="16px",
padding="40px 32px",
align_items="start",
),
rx.divider(orientation="vertical"),
rx.vstack(
rx.image(
src="/qrcode.png",
width="160px",
height="160px",
padding="8px",
border="1px solid #eee",
border_radius="8px",
bg="white",
),
rx.text(
"添加微信申请使用",
font_size="18px",
font_weight="600",
margin_top="16px",
),
align_items="center",
width="320px",
padding="40px 32px",
style={"background": "#f8f9fc"},
),
),
position="fixed",
top="0",
left="0",
width="100vw",
height="100vh",
bg="rgba(255, 255, 255, 0.85)",
z_index="9999",
overflow="hidden",
)
def render_index() -> rx.Component: def render_index() -> rx.Component:
""" """
首页 首页
""" """
return rx.hstack( return rx.fragment(
# 渲染侧边栏 rx.hstack(
sidebar(), # 渲染侧边栏
# 根据用户所点中的侧边栏图标导航按钮渲染相应页面 render_sidebar(),
rx.match( # 根据侧边栏状态中激活的导航按钮渲染相应页面(本项目采用卡片布局)
SidebarState.get_activated_button, rx.match(
# 渲染知识库页面 SidebarState.activated_nav_button,
("library", render_library()), # 渲染知识库页面
# 渲染会话页面 ("library", render_library()),
render_conversation(), # 渲染会话页面
render_conversation(),
),
width="100%",
height="100vh",
overflow="auto",
padding="8px 8px 8px 0",
background="var(--prismui-background-1)",
# 挂载事件:
on_mount=AuthState.check,
),
rx.cond(
AuthState.user_id == "",
render_login_modal(),
rx.fragment(),
), ),
width="100%",
height="100vh",
padding="8px 8px 8px 0",
overflow="auto",
box_sizing="border-box",
background="var(--mc-global-bg)",
# 挂载事件:因测试需模拟已认证
on_mount=AuthState.authenticate,
) )

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from application.states.auth import AuthState from application.states.auth import AuthState
from application.states.conversation import ConversationState from application.states.conversation import ConversationState
from application.states.conversation_history import ConversationHistoryState from application.states.conversation_historycollapse import ConversationHistoryCollapseState
from application.states.create_conversation import CreateConversationState from application.states.create_conversation import CreateConversationState
from application.states.database import DatabaseState from application.states.database import DatabaseState
from application.states.sidebar import SidebarState from application.states.sidebar import SidebarState
@ -9,7 +9,7 @@ from application.states.sidebar import SidebarState
__all__ = [ __all__ = [
"AuthState", "AuthState",
"ConversationHistoryState", "ConversationHistoryCollapseState",
"ConversationState", "ConversationState",
"CreateConversationState", "CreateConversationState",
"DatabaseState", "DatabaseState",

View File

@ -4,6 +4,11 @@
""" """
import reflex as rx import reflex as rx
import re
from sqlmodel import select
from application.models.user import EmailVerifyCode
from application.database.base import get_async_db
class AuthState(rx.State): class AuthState(rx.State):
""" """
@ -13,10 +18,112 @@ class AuthState(rx.State):
# 当前登录的用户唯一标识(通过本地存储同步) # 当前登录的用户唯一标识(通过本地存储同步)
user_id: str = rx.LocalStorage("user_id", sync=True) user_id: str = rx.LocalStorage("user_id", sync=True)
email: str = ""
captcha: str = ""
is_policies_agreed: bool = False
@rx.event @rx.event
async def authenticate(self) -> None: async def check(self) -> None:
""" """
认证 检查认证状态
""" """
self.user_id = "1" self.user_id = ""
@rx.event
def set_email(self, email: str) -> None:
"""
设置邮箱
:param email: 邮箱
:return: None
"""
self.email = email.strip()
@rx.event
def set_captcha(self, captcha: str) -> None:
"""
设置验证码
:param captcha: 验证码
:return: None
"""
self.captcha = captcha.strip()
@rx.event
def toggle_policies_agreed(self) -> None:
"""
切换协议同意状态
:return: None
"""
self.is_policies_agreed = not self.is_policies_agreed
@rx.event
async def send_email_code(self):
"""生成并存储邮箱验证码(异步数据库,不阻塞事件循环)"""
email_rule = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.fullmatch(email_rule, self.email):
return rx.toast("请输入合法邮箱地址", color="red")
# 异步数据库会话
async for db in get_async_db():
# 清理该邮箱旧验证码await 异步查询)
old_codes = await db.exec(
select(EmailVerifyCode).where(EmailVerifyCode.email == self.email)
)
old_list = old_codes.all()
for item in old_list:
await db.delete(item)
# 6位验证码5分钟有效期
import random
from datetime import datetime, timedelta
code_str = "".join(random.choices("0123456789", k=6))
expire_time = datetime.utcnow() + timedelta(minutes=5)
db.add(
EmailVerifyCode(email=self.email, code=code_str, expire_at=expire_time)
)
await db.commit()
# 本地调试打印验证码
print(f"邮箱 {self.email} 测试验证码:{code_str}")
return rx.toast("验证码已发送(控制台查看测试码)", color="green")
@rx.event
async def login_by_email_code(self):
"""邮箱验证码登录,不存在用户自动注册"""
if not self.email:
return rx.toast("请填写邮箱", color="red")
if not self.code:
return rx.toast("请填写验证码", color="red")
if not self.agree_policy:
return rx.toast("请勾选同意用户协议与隐私政策", color="red")
db = get_db_session()
# 查询有效验证码
code_record = db.exec(
select(EmailVerifyCode).where(
EmailVerifyCode.email == self.email, EmailVerifyCode.code == self.code
)
).first()
if not code_record or code_record.is_expired():
return rx.toast("验证码错误或已过期", color="red")
# 一次性验证码使用后删除
db.delete(code_record)
# 查找用户,无则新建
user = db.exec(select(User).where(User.email == self.email)).first()
if not user:
user = User(email=self.email, nickname=self.email.split("@")[0])
db.add(user)
user.last_login_at = datetime.utcnow()
db.commit()
db.refresh(user)
# 登录成功,弹窗自动消失
self.user_id = str(user.id)
self.email = ""
self.code = ""
self.agree_policy = False
return rx.toast("登录成功", color="green")

View File

@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
"""
会话历史状态
"""
import reflex as rx
class ConversationHistoryState(rx.State):
"""
会话历史状态
"""
# 会话历史折叠面板展开状态True表示展开False表示收起
is_collapse_expanded: bool = False
@rx.event
def toggle_collapse(self) -> None:
"""
打开/关闭会话历史折叠面板
:return: None
"""
self.is_collapse_expanded = not self.is_collapse_expanded

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
"""
会话历史折叠面板状态
"""
import reflex as rx
class ConversationHistoryCollapseState(rx.State):
"""
会话历史折叠面板状态
"""
# 展开状态True表示展开False表示折叠
is_expanded: bool = False
@rx.event
def toggle_expanded(self) -> None:
"""
切换展开状态
:return: None
"""
self.is_expanded = not self.is_expanded

View File

@ -13,9 +13,9 @@ class SidebarState(rx.State):
""" """
@rx.var @rx.var
async def generate_storage_key(self) -> str: async def storage_key(self) -> str:
""" """
生成 LocalStorage LocalStorage
""" """
# 获取认证状态 # 获取认证状态
auth = await self.get_state(AuthState) auth = await self.get_state(AuthState)
@ -23,19 +23,12 @@ class SidebarState(rx.State):
user_id = auth.user_id or "default" user_id = auth.user_id or "default"
return f"sidebar_{user_id}" return f"sidebar_{user_id}"
# 侧边栏中激活的图标导航按钮 # 激活的导航按钮
activated_button: str = rx.LocalStorage(generate_storage_key, sync=True) activated_nav_button: str = rx.LocalStorage(storage_key, sync=True)
@rx.var
def get_activated_button(self) -> str:
"""
获取侧边栏中激活的图标导航按钮
"""
return self.activated_button
@rx.event @rx.event
def switch_activated_button(self, button: str): def switch_nav_button(self, button: str):
""" """
将指定图标导航按钮设置为侧边栏中激活的图标导航按钮 将指定导航按钮设置为激活的导航按钮
""" """
self.activated_button = button self.activated_nav_button = button

Binary file not shown.