优化了登录页

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),
db: AsyncSession = Depends(get_db)
):
"""获取指定日期的文档操作日志"""
"""获取指定日期的文档操作日志(按项目+文件+操作类型聚合)"""
# 解析日期
try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
@ -284,14 +284,51 @@ async def get_document_activity(
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:
# 解析 detail 字段获取文件路径和项目ID
detail = json.loads(log.detail) if log.detail else {}
project_id = detail.get('project_id')
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_storage_key = None
@ -306,29 +343,33 @@ async def get_document_activity(
# 检查文件是否存在(仅针对非删除操作)
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)
file_exists = os.path.exists(full_path) and os.path.isfile(full_path)
# 操作类型中文映射
operation_map = {
OperationType.CREATE_FILE: "创建文件",
OperationType.SAVE_FILE: "保存文件",
OperationType.DELETE_FILE: "删除文件",
OperationType.RENAME_FILE: "重命名文件",
OperationType.MOVE_FILE: "移动文件",
}
# 生成描述文本
operation_text = operation_map.get(operation_type, operation_type)
if agg['count'] > 1:
description = f"{operation_text} {agg['count']}"
else:
description = operation_text
logs_data.append({
"id": log.id,
"operation_type": operation_map.get(log.operation_type, log.operation_type),
"id": f"{project_id}_{file_path}_{operation_type}", # 唯一ID
"operation_type": description,
"operation_count": agg['count'],
"project_id": project_id,
"project_name": project_name or "未知项目",
"file_path": file_path or "未知文件",
"file_exists": file_exists,
"created_at": log.created_at.isoformat() if log.created_at else None,
"detail": detail,
"first_time": agg['first_time'].isoformat() if agg['first_time'] else None,
"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})

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

View File

@ -208,7 +208,7 @@ function AppHeader({ collapsed, onToggle }) {
<div className="header-left">
{/* 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>
{/* 折叠按钮 */}

View File

@ -1,39 +1,269 @@
.login-container {
min-height: 100vh;
.login-page {
display: flex;
min-height: 100vh;
background: #fff;
}
/* 左侧介绍区域 */
.login-left {
flex: 1;
background: linear-gradient(135deg, #e6f0ff 0%, #f0f5ff 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 60px;
position: relative;
}
.login-card {
width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
border-radius: 8px;
.login-left-content {
max-width: 560px;
width: 100%;
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.login-header {
text-align: center;
/* Logo */
.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;
}
.login-header h1 {
font-size: 32px;
font-weight: bold;
color: #667eea;
.intro-title .highlight {
color: #1890ff;
}
.intro-desc {
font-size: 16px;
line-height: 1.8;
color: #666;
margin: 0;
}
.login-header p {
font-size: 14px;
color: #666;
margin-top: 8px;
/* 底部信息 */
.footer-info {
margin-top: auto;
}
.login-footer {
margin-top: 20px;
text-align: center;
color: white;
font-size: 12px;
.footer-links {
display: flex;
gap: 24px;
margin-bottom: 16px;
}
.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 { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Card, Tabs, Checkbox } from 'antd'
import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons'
import { Form, Input, Button, Tabs } from 'antd'
import { UserOutlined, LockOutlined, MailOutlined, ArrowRightOutlined } from '@ant-design/icons'
import { login, register } from '@/api/auth'
import { getUserMenus } from '@/api/menu'
import useUserStore from '@/stores/userStore'
@ -11,33 +11,16 @@ import './Login.css'
function Login() {
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState('login')
const [rememberMe, setRememberMe] = useState(false)
const [loginForm] = Form.useForm()
const navigate = useNavigate()
const { setUser, setToken } = useUserStore()
//
useEffect(() => {
const savedUsername = localStorage.getItem('remembered_username')
if (savedUsername) {
loginForm.setFieldsValue({ username: savedUsername })
setRememberMe(true)
}
}, [])
const handleLogin = async (values) => {
setLoading(true)
try {
const res = await login(values)
Toast.success('登录成功')
// ""
if (rememberMe) {
localStorage.setItem('remembered_username', values.username)
} else {
localStorage.removeItem('remembered_username')
}
// token
localStorage.setItem('access_token', res.data.access_token)
localStorage.setItem('user_info', JSON.stringify(res.data.user))
@ -92,147 +75,199 @@ function Login() {
}
return (
<div className="login-container">
<Card className="login-card">
<div className="login-header">
<h1>NEX Docus</h1>
<p>团队协作文档管理平台</p>
<div className="login-page">
{/* 左侧介绍区域 */}
<div className="login-left">
<div className="login-left-content">
<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>
<Tabs activeKey={activeTab} onChange={setActiveTab} centered>
<Tabs.TabPane tab="登录" key="login">
<Form
form={loginForm}
name="login"
onFinish={handleLogin}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
{/* 右侧登录区域 */}
<div className="login-right">
<div className="login-form-container">
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'login',
label: '账号登录',
children: (
<div className="form-wrapper">
<p className="form-subtitle">欢迎回来请输入您的登录凭证</p>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
<Form
form={loginForm}
name="login"
onFinish={handleLogin}
autoComplete="off"
layout="vertical"
>
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="请输入用户名"
size="large"
/>
</Form.Item>
<Form.Item>
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
>
记住用户
</Checkbox>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="请输入密码"
size="large"
visibilityToggle
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
>
登录
</Button>
</Form.Item>
</Form>
</Tabs.TabPane>
<Form.Item style={{ marginBottom: '12px' }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
size="large"
icon={<ArrowRightOutlined />}
iconPosition="end"
>
立即登录
</Button>
</Form.Item>
</Form>
</div>
)
},
{
key: 'register',
label: '注册账号',
children: (
<div className="form-wrapper">
<p className="form-subtitle">创建您的账号开始使用平台</p>
<Tabs.TabPane tab="注册" key="register">
<Form
name="register"
onFinish={handleRegister}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' },
]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form
name="register"
onFinish={handleRegister}
autoComplete="off"
layout="vertical"
>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' },
]}
>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="用户名"
size="large"
/>
</Form.Item>
<Form.Item
name="email"
rules={[
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined />}
placeholder="邮箱(选填)"
/>
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined style={{ color: '#bfbfbf' }} />}
placeholder="邮箱(选填)"
size="large"
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' },
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="密码"
size="large"
/>
</Form.Item>
<Form.Item
name="confirm"
dependencies={['password']}
rules={[
{ required: true, message: '请确认密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="确认密码"
/>
</Form.Item>
<Form.Item
label="确认密码"
name="confirm"
dependencies={['password']}
rules={[
{ required: true, message: '请确认密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="确认密码"
size="large"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
>
注册
</Button>
</Form.Item>
</Form>
</Tabs.TabPane>
</Tabs>
</Card>
<div className="login-footer">
<p>重置密码请联系管理员</p>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
size="large"
>
注册
</Button>
</Form.Item>
</Form>
</div>
)
}
]}
/>
</div>
</div>
</div>
)