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 +
+ NexDocus +
+ +
+

+ 团队协作 +
+ 文档管理平台 +

+

+ 全流程文档共享,提升团队协作效率的新一代解决方案。 +

+
+ +
+
+ 团队知识共享 + 大模型能力加持 +
+

© 2024 NexDocus. All rights reserved.

+
+
- - -
- - } - placeholder="用户名" - /> - + {/* 右侧登录区域 */} +
+
+ +

欢迎回来,请输入您的登录凭证。

- - } - placeholder="密码" - /> - + + + } + placeholder="请输入用户名" + size="large" + /> + - - setRememberMe(e.target.checked)} - > - 记住用户 - - + + } + placeholder="请输入密码" + size="large" + visibilityToggle + /> + - - - - - + + + + +
+ ) + }, + { + key: 'register', + label: '注册账号', + children: ( +
+

创建您的账号,开始使用平台。

- -
- - } - placeholder="用户名" - /> - + + + } + placeholder="用户名" + size="large" + /> + - - } - placeholder="邮箱(选填)" - /> - + + } + placeholder="邮箱(选填)" + size="large" + /> + - - } - placeholder="密码" - /> - + + } + placeholder="密码" + size="large" + /> + - ({ - validator(_, value) { - if (!value || getFieldValue('password') === value) { - return Promise.resolve() - } - return Promise.reject(new Error('两次输入的密码不一致')) - }, - }), - ]} - > - } - placeholder="确认密码" - /> - + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve() + } + return Promise.reject(new Error('两次输入的密码不一致')) + }, + }), + ]} + > + } + placeholder="确认密码" + size="large" + /> + - - - -
-
- - - -
-

重置密码请联系管理员

+ + + + +
+ ) + } + ]} + /> +
)