优化了登录页

main
mula.liu 2026-01-14 11:35:50 +08:00
parent 72000d5660
commit ce9ccd70ef
6 changed files with 499 additions and 193 deletions

View File

@ -249,7 +249,7 @@ async def get_document_activity(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""获取指定日期的文档操作日志""" """获取指定日期的文档操作日志(按项目+文件+操作类型聚合)"""
# 解析日期 # 解析日期
try: try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date() target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
@ -284,14 +284,51 @@ async def get_document_activity(
logs = result.scalars().all() logs = result.scalars().all()
# 构建返回数据,包含项目信息 # 操作类型中文映射
logs_data = [] operation_map = {
OperationType.CREATE_FILE: "创建",
OperationType.SAVE_FILE: "保存",
OperationType.DELETE_FILE: "删除",
OperationType.RENAME_FILE: "重命名",
OperationType.MOVE_FILE: "移动",
}
# 聚合日志:按 (project_id, file_path, operation_type) 分组
aggregated = {}
for log in logs: for log in logs:
# 解析 detail 字段获取文件路径和项目ID # 解析 detail 字段获取文件路径和项目ID
detail = json.loads(log.detail) if log.detail else {} detail = json.loads(log.detail) if log.detail else {}
project_id = detail.get('project_id') project_id = detail.get('project_id')
file_path = detail.get('path') or detail.get('file_path') or detail.get('old_path') file_path = detail.get('path') or detail.get('file_path') or detail.get('old_path')
# 创建聚合键
key = (project_id, file_path, log.operation_type)
if key not in aggregated:
aggregated[key] = {
'project_id': project_id,
'file_path': file_path,
'operation_type': log.operation_type,
'count': 0,
'first_time': log.created_at,
'last_time': log.created_at,
}
aggregated[key]['count'] += 1
# 更新最早和最晚时间
if log.created_at < aggregated[key]['first_time']:
aggregated[key]['first_time'] = log.created_at
if log.created_at > aggregated[key]['last_time']:
aggregated[key]['last_time'] = log.created_at
# 构建返回数据,包含项目信息
logs_data = []
for key, agg in aggregated.items():
project_id = agg['project_id']
file_path = agg['file_path']
operation_type = agg['operation_type']
# 获取项目信息 # 获取项目信息
project_name = None project_name = None
project_storage_key = None project_storage_key = None
@ -306,29 +343,33 @@ async def get_document_activity(
# 检查文件是否存在(仅针对非删除操作) # 检查文件是否存在(仅针对非删除操作)
file_exists = False file_exists = False
if project_storage_key and file_path and log.operation_type != OperationType.DELETE_FILE: if project_storage_key and file_path and operation_type != OperationType.DELETE_FILE:
full_path = os.path.join(settings.PROJECTS_PATH, project_storage_key, file_path) full_path = os.path.join(settings.PROJECTS_PATH, project_storage_key, file_path)
file_exists = os.path.exists(full_path) and os.path.isfile(full_path) file_exists = os.path.exists(full_path) and os.path.isfile(full_path)
# 操作类型中文映射 # 生成描述文本
operation_map = { operation_text = operation_map.get(operation_type, operation_type)
OperationType.CREATE_FILE: "创建文件", if agg['count'] > 1:
OperationType.SAVE_FILE: "保存文件", description = f"{operation_text} {agg['count']}"
OperationType.DELETE_FILE: "删除文件", else:
OperationType.RENAME_FILE: "重命名文件", description = operation_text
OperationType.MOVE_FILE: "移动文件",
}
logs_data.append({ logs_data.append({
"id": log.id, "id": f"{project_id}_{file_path}_{operation_type}", # 唯一ID
"operation_type": operation_map.get(log.operation_type, log.operation_type), "operation_type": description,
"operation_count": agg['count'],
"project_id": project_id, "project_id": project_id,
"project_name": project_name or "未知项目", "project_name": project_name or "未知项目",
"file_path": file_path or "未知文件", "file_path": file_path or "未知文件",
"file_exists": file_exists, "file_exists": file_exists,
"created_at": log.created_at.isoformat() if log.created_at else None, "first_time": agg['first_time'].isoformat() if agg['first_time'] else None,
"detail": detail, "last_time": agg['last_time'].isoformat() if agg['last_time'] else None,
"created_at": agg['last_time'].isoformat() if agg['last_time'] else None, # 用最后操作时间排序
}) })
# 按最后操作时间降序排序
logs_data.sort(key=lambda x: x['last_time'] if x['last_time'] else '', reverse=True)
return success_response(data={"logs": logs_data}) return success_response(data={"logs": logs_data})

View File

@ -1,6 +1,6 @@
# NEX Docus Frontend # NexDocus Frontend
NEX Docus 前端项目 - 基于 React + Vite + Ant Design 构建。 NexDocus 前端项目 - 基于 React + Vite + Ant Design 构建。
## 技术栈 ## 技术栈

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NEX Docus - 文档管理平台</title> <title>NexDocus - 文档管理平台</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -208,7 +208,7 @@ function AppHeader({ collapsed, onToggle }) {
<div className="header-left"> <div className="header-left">
{/* Logo 区域 */} {/* Logo 区域 */}
<div className="header-logo"> <div className="header-logo">
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NEX Docus</h2> <h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NexDocus</h2>
</div> </div>
{/* 折叠按钮 */} {/* 折叠按钮 */}

View File

@ -1,39 +1,269 @@
.login-container { .login-page {
min-height: 100vh; display: flex;
min-height: 100vh;
background: #fff;
}
/* 左侧介绍区域 */
.login-left {
flex: 1;
background: linear-gradient(135deg, #e6f0ff 0%, #f0f5ff 100%);
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 60px;
position: relative;
} }
.login-card { .login-left-content {
width: 400px; max-width: 560px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); width: 100%;
border-radius: 8px; display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
} }
.login-header { /* Logo */
text-align: center; .logo-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 80px;
}
.logo-icon {
width: 48px;
height: 48px;
background: #1890ff;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.logo-text {
font-size: 24px;
font-weight: 600;
color: #1890ff;
}
/* 介绍内容 */
.intro-section {
flex: 1;
}
.intro-title {
font-size: 48px;
font-weight: 700;
line-height: 1.3;
color: #1f1f1f;
margin-bottom: 24px; margin-bottom: 24px;
} }
.login-header h1 { .intro-title .highlight {
font-size: 32px; color: #1890ff;
font-weight: bold; }
color: #667eea;
.intro-desc {
font-size: 16px;
line-height: 1.8;
color: #666;
margin: 0; margin: 0;
} }
.login-header p { /* 底部信息 */
font-size: 14px; .footer-info {
color: #666; margin-top: auto;
margin-top: 8px;
} }
.login-footer { .footer-links {
margin-top: 20px; display: flex;
text-align: center; gap: 24px;
color: white; margin-bottom: 16px;
font-size: 12px; }
.footer-links span {
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 6px;
}
.footer-links span::before {
content: '';
width: 6px;
height: 6px;
background: #1890ff;
border-radius: 50%;
}
.copyright {
font-size: 12px;
color: #999;
margin: 0;
}
/* 右侧登录区域 */
.login-right {
flex: 0 0 520px;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: white;
}
.login-form-container {
width: 100%;
max-width: 400px;
}
.login-form-container .ant-tabs-nav {
margin-bottom: 32px;
}
.login-form-container .ant-tabs-tab {
font-size: 16px;
font-weight: 500;
padding: 12px 16px;
}
.form-wrapper {
padding-top: 8px;
}
.form-subtitle {
font-size: 14px;
color: #8c8c8c;
margin-bottom: 32px;
text-align: left;
}
/* 表单样式 */
.login-form-container .ant-form-item-label > label {
font-weight: 500;
color: #262626;
}
.login-form-container .ant-input-affix-wrapper,
.login-form-container .ant-input {
border-radius: 8px;
padding: 12px 16px;
background: #fafafa;
border: 1px solid #f0f0f0;
transition: all 0.3s;
}
.login-form-container .ant-input-affix-wrapper:hover,
.login-form-container .ant-input:hover {
background: #fff;
border-color: #d9d9d9;
}
.login-form-container .ant-input-affix-wrapper-focused,
.login-form-container .ant-input-affix-wrapper:focus,
.login-form-container .ant-input:focus {
background: #fff;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.login-form-container .ant-btn-primary {
height: 48px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
transition: all 0.3s;
}
.login-form-container .ant-btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
}
/* 测试账号信息 */
.test-account-info {
margin-top: 24px;
padding: 16px;
background: #f0f5ff;
border-radius: 8px;
border-left: 4px solid #1890ff;
text-align: center;
}
.test-account-info p {
margin: 0;
font-size: 13px;
color: #595959;
}
.test-account-info strong {
color: #1890ff;
font-weight: 600;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.login-left {
padding: 40px;
}
.intro-title {
font-size: 40px;
}
.login-right {
flex: 0 0 480px;
}
}
@media (max-width: 992px) {
.login-page {
flex-direction: column;
}
.login-left {
flex: none;
min-height: 40vh;
padding: 40px 24px;
}
.login-left-content {
max-width: 100%;
}
.logo-section {
margin-bottom: 40px;
}
.intro-title {
font-size: 32px;
}
.footer-info {
display: none;
}
.login-right {
flex: 1;
padding: 40px 24px;
}
}
@media (max-width: 576px) {
.login-right {
padding: 24px 16px;
}
.login-form-container {
max-width: 100%;
}
.intro-title {
font-size: 28px;
}
} }

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Card, Tabs, Checkbox } from 'antd' import { Form, Input, Button, Tabs } from 'antd'
import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons' import { UserOutlined, LockOutlined, MailOutlined, ArrowRightOutlined } from '@ant-design/icons'
import { login, register } from '@/api/auth' import { login, register } from '@/api/auth'
import { getUserMenus } from '@/api/menu' import { getUserMenus } from '@/api/menu'
import useUserStore from '@/stores/userStore' import useUserStore from '@/stores/userStore'
@ -11,33 +11,16 @@ import './Login.css'
function Login() { function Login() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState('login') const [activeTab, setActiveTab] = useState('login')
const [rememberMe, setRememberMe] = useState(false)
const [loginForm] = Form.useForm() const [loginForm] = Form.useForm()
const navigate = useNavigate() const navigate = useNavigate()
const { setUser, setToken } = useUserStore() const { setUser, setToken } = useUserStore()
//
useEffect(() => {
const savedUsername = localStorage.getItem('remembered_username')
if (savedUsername) {
loginForm.setFieldsValue({ username: savedUsername })
setRememberMe(true)
}
}, [])
const handleLogin = async (values) => { const handleLogin = async (values) => {
setLoading(true) setLoading(true)
try { try {
const res = await login(values) const res = await login(values)
Toast.success('登录成功') Toast.success('登录成功')
// ""
if (rememberMe) {
localStorage.setItem('remembered_username', values.username)
} else {
localStorage.removeItem('remembered_username')
}
// token // token
localStorage.setItem('access_token', res.data.access_token) localStorage.setItem('access_token', res.data.access_token)
localStorage.setItem('user_info', JSON.stringify(res.data.user)) localStorage.setItem('user_info', JSON.stringify(res.data.user))
@ -92,147 +75,199 @@ function Login() {
} }
return ( return (
<div className="login-container"> <div className="login-page">
<Card className="login-card"> {/* 左侧介绍区域 */}
<div className="login-header"> <div className="login-left">
<h1>NEX Docus</h1> <div className="login-left-content">
<p>团队协作文档管理平台</p> <div className="logo-section">
<div className="logo-icon">
<img src="/favicon.svg" alt="NexDocus" width="42" height="42" />
</div>
<span className="logo-text">NexDocus</span>
</div>
<div className="intro-section">
<h1 className="intro-title">
团队协作
<br />
<span className="highlight">文档管理平台</span>
</h1>
<p className="intro-desc">
全流程文档共享提升团队协作效率的新一代解决方案
</p>
</div>
<div className="footer-info">
<div className="footer-links">
<span>团队知识共享</span>
<span>大模型能力加持</span>
</div>
<p className="copyright">© 2024 NexDocus. All rights reserved.</p>
</div>
</div> </div>
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab} centered> {/* 右侧登录区域 */}
<Tabs.TabPane tab="登录" key="login"> <div className="login-right">
<Form <div className="login-form-container">
form={loginForm} <Tabs
name="login" activeKey={activeTab}
onFinish={handleLogin} onChange={setActiveTab}
autoComplete="off" items={[
size="large" {
> key: 'login',
<Form.Item label: '账号登录',
name="username" children: (
rules={[{ required: true, message: '请输入用户名' }]} <div className="form-wrapper">
> <p className="form-subtitle">欢迎回来请输入您的登录凭证</p>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item <Form
name="password" form={loginForm}
rules={[{ required: true, message: '请输入密码' }]} name="login"
> onFinish={handleLogin}
<Input.Password autoComplete="off"
prefix={<LockOutlined />} layout="vertical"
placeholder="密码" >
/> <Form.Item
</Form.Item> label="用户名"
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="请输入用户名"
size="large"
/>
</Form.Item>
<Form.Item> <Form.Item
<Checkbox label="密码"
checked={rememberMe} name="password"
onChange={(e) => setRememberMe(e.target.checked)} rules={[{ required: true, message: '请输入密码' }]}
> >
记住用户 <Input.Password
</Checkbox> prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
</Form.Item> placeholder="请输入密码"
size="large"
visibilityToggle
/>
</Form.Item>
<Form.Item> <Form.Item style={{ marginBottom: '12px' }}>
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={loading} loading={loading}
block block
> size="large"
登录 icon={<ArrowRightOutlined />}
</Button> iconPosition="end"
</Form.Item> >
</Form> 立即登录
</Tabs.TabPane> </Button>
</Form.Item>
</Form>
</div>
)
},
{
key: 'register',
label: '注册账号',
children: (
<div className="form-wrapper">
<p className="form-subtitle">创建您的账号开始使用平台</p>
<Tabs.TabPane tab="注册" key="register"> <Form
<Form name="register"
name="register" onFinish={handleRegister}
onFinish={handleRegister} autoComplete="off"
autoComplete="off" layout="vertical"
size="large" >
> <Form.Item
<Form.Item label="用户名"
name="username" name="username"
rules={[ rules={[
{ required: true, message: '请输入用户名' }, { required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' }, { min: 3, message: '用户名至少3个字符' },
]} ]}
> >
<Input <Input
prefix={<UserOutlined />} prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="用户名" placeholder="用户名"
/> size="large"
</Form.Item> />
</Form.Item>
<Form.Item <Form.Item
name="email" label="邮箱"
rules={[ name="email"
{ type: 'email', message: '请输入有效的邮箱地址' }, rules={[
]} { type: 'email', message: '请输入有效的邮箱地址' },
> ]}
<Input >
prefix={<MailOutlined />} <Input
placeholder="邮箱(选填)" prefix={<MailOutlined style={{ color: '#bfbfbf' }} />}
/> placeholder="邮箱(选填)"
</Form.Item> size="large"
/>
</Form.Item>
<Form.Item <Form.Item
name="password" label="密码"
rules={[ name="password"
{ required: true, message: '请输入密码' }, rules={[
{ min: 6, message: '密码至少6个字符' }, { required: true, message: '请输入密码' },
]} { min: 6, message: '密码至少6个字符' },
> ]}
<Input.Password >
prefix={<LockOutlined />} <Input.Password
placeholder="密码" prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
/> placeholder="密码"
</Form.Item> size="large"
/>
</Form.Item>
<Form.Item <Form.Item
name="confirm" label="确认密码"
dependencies={['password']} name="confirm"
rules={[ dependencies={['password']}
{ required: true, message: '请确认密码' }, rules={[
({ getFieldValue }) => ({ { required: true, message: '请确认密码' },
validator(_, value) { ({ getFieldValue }) => ({
if (!value || getFieldValue('password') === value) { validator(_, value) {
return Promise.resolve() if (!value || getFieldValue('password') === value) {
} return Promise.resolve()
return Promise.reject(new Error('两次输入的密码不一致')) }
}, return Promise.reject(new Error('两次输入的密码不一致'))
}), },
]} }),
> ]}
<Input.Password >
prefix={<LockOutlined />} <Input.Password
placeholder="确认密码" prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
/> placeholder="确认密码"
</Form.Item> size="large"
/>
</Form.Item>
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
loading={loading} loading={loading}
block block
> size="large"
注册 >
</Button> 注册
</Form.Item> </Button>
</Form> </Form.Item>
</Tabs.TabPane> </Form>
</Tabs> </div>
</Card> )
}
<div className="login-footer"> ]}
<p>重置密码请联系管理员</p> />
</div>
</div> </div>
</div> </div>
) )