diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py
index b99b0f1..cb6a91b 100644
--- a/backend/app/api/v1/dashboard.py
+++ b/backend/app/api/v1/dashboard.py
@@ -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})
+
diff --git a/frontend/README.md b/frontend/README.md
index ec1bcc5..3c8d729 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -1,6 +1,6 @@
-# NEX Docus Frontend
+# NexDocus Frontend
-NEX Docus 前端项目 - 基于 React + Vite + Ant Design 构建。
+NexDocus 前端项目 - 基于 React + Vite + Ant Design 构建。
## 技术栈
diff --git a/frontend/index.html b/frontend/index.html
index 544c057..88ae2ab 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
-
NEX Docus - 文档管理平台
+ NexDocus - 文档管理平台
diff --git a/frontend/src/components/MainLayout/AppHeader.jsx b/frontend/src/components/MainLayout/AppHeader.jsx
index 6df8599..48bfefe 100644
--- a/frontend/src/components/MainLayout/AppHeader.jsx
+++ b/frontend/src/components/MainLayout/AppHeader.jsx
@@ -208,7 +208,7 @@ function AppHeader({ collapsed, onToggle }) {
{/* Logo 区域 */}
-
NEX Docus
+ NexDocus
{/* 折叠按钮 */}
diff --git a/frontend/src/pages/Login/Login.css b/frontend/src/pages/Login/Login.css
index c1ce710..fca3723 100644
--- a/frontend/src/pages/Login/Login.css
+++ b/frontend/src/pages/Login/Login.css
@@ -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;
+ }
}
diff --git a/frontend/src/pages/Login/Login.jsx b/frontend/src/pages/Login/Login.jsx
index 5ca5fa7..8e5bd9b 100644
--- a/frontend/src/pages/Login/Login.jsx
+++ b/frontend/src/pages/Login/Login.jsx
@@ -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 (
-
-
-
-
NEX Docus
-
团队协作文档管理平台
+
+ {/* 左侧介绍区域 */}
+
+
+
+
+

+
+
NexDocus
+
+
+
+
+ 团队协作
+
+ 文档管理平台
+
+
+ 全流程文档共享,提升团队协作效率的新一代解决方案。
+
+
+
+
+
+ 团队知识共享
+ 大模型能力加持
+
+
© 2024 NexDocus. All rights reserved.
+
+
-
-
-
- }
- placeholder="用户名"
- />
-
+ {/* 右侧登录区域 */}
+
+
+ )
+ },
+ {
+ key: 'register',
+ label: '注册账号',
+ children: (
+
)