diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index d6afb82..3fb5c38 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -3,7 +3,7 @@ """ from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import delete, select, or_ +from sqlalchemy import delete, select, or_, func from typing import List import uuid import secrets @@ -54,6 +54,18 @@ def get_document_count(storage_key: str) -> int: return 0 +async def attach_member_counts(db: AsyncSession, projects) -> dict: + """批量查询项目的参与人数量(含所有者),返回 {project_id: count}""" + if not projects: + return {} + result = await db.execute( + select(ProjectMember.project_id, func.count(ProjectMember.id)) + .where(ProjectMember.project_id.in_([p.id for p in projects])) + .group_by(ProjectMember.project_id) + ) + return dict(result.all()) + + @router.get("/", response_model=dict) async def get_my_projects( current_user: User = Depends(get_current_user), @@ -80,10 +92,12 @@ async def get_my_projects( # 合并结果 all_projects = owned_projects + member_projects + member_counts = await attach_member_counts(db, all_projects) projects_data = [] for p in all_projects: p_dict = ProjectResponse.from_orm(p).dict() p_dict['doc_count'] = get_document_count(p.storage_key) + p_dict['member_count'] = member_counts.get(p.id, 0) projects_data.append(p_dict) return success_response(data=projects_data) @@ -99,10 +113,12 @@ async def get_owned_projects( select(Project).where(Project.owner_id == current_user.id, Project.status == 1) ) projects = result.scalars().all() + member_counts = await attach_member_counts(db, projects) projects_data = [] for p in projects: p_dict = ProjectResponse.from_orm(p).dict() p_dict['doc_count'] = get_document_count(p.storage_key) + p_dict['member_count'] = member_counts.get(p.id, 0) projects_data.append(p_dict) return success_response(data=projects_data) @@ -125,6 +141,9 @@ async def get_shared_projects( ) projects_with_info = result.all() + projects = [project for project, _, _ in projects_with_info] + member_counts = await attach_member_counts(db, projects) + projects_data = [] for project, owner, member in projects_with_info: project_dict = ProjectResponse.from_orm(project).dict() @@ -132,6 +151,7 @@ async def get_shared_projects( project_dict['owner_nickname'] = owner.nickname project_dict['user_role'] = member.role # 添加用户角色 project_dict['doc_count'] = get_document_count(project.storage_key) + project_dict['member_count'] = member_counts.get(project.id, 0) projects_data.append(project_dict) return success_response(data=projects_data) diff --git a/frontend/src/index.css b/frontend/src/index.css index f380e4a..9cf4a4e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -19,6 +19,7 @@ --toolbar-bg: #fafafa; --link-color: #1677ff; --panel-shadow: 0 6px 20px rgba(15, 23, 42, 0.06); + --selected-bg: rgba(22, 119, 255, 0.08); } body.dark { @@ -42,6 +43,7 @@ body.dark { --toolbar-bg: #1f1f1f; --link-color: #177ddc; --panel-shadow: 0 10px 24px rgba(0, 0, 0, 0.22); + --selected-bg: rgba(23, 125, 220, 0.18); } body { diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css index 54c9a86..6b3946e 100644 --- a/frontend/src/pages/Document/DocumentEditor.css +++ b/frontend/src/pages/Document/DocumentEditor.css @@ -166,14 +166,36 @@ white-space: nowrap; } -/* 选中的文件夹样式 */ +/* ===== 选中节点:文字、图标与背景同步高亮 ===== */ + +/* 选中叶子节点(文件)的整行背景 */ +.file-tree .ant-menu-item-selected { + background-color: var(--selected-bg) !important; +} + +/* 选中文件夹标题的背景 */ .file-tree .folder-selected>.ant-menu-submenu-title { - background-color: var(--item-hover-bg) !important; + background-color: var(--selected-bg) !important; color: var(--link-color) !important; } .file-tree .folder-selected>.ant-menu-submenu-title:hover { - background-color: var(--item-hover-bg) !important; + background-color: var(--selected-bg) !important; +} + +/* 选中时文字颜色与字重(修复仅图标高亮、文字不变的问题) */ +.file-tree .ant-menu-item-selected .tree-node-wrapper, +.file-tree .folder-selected>.ant-menu-submenu-title .tree-node-wrapper, +.file-tree .ant-menu-submenu-selected>.ant-menu-submenu-title .tree-node-wrapper { + color: var(--link-color) !important; + font-weight: 500; +} + +/* 选中时图标颜色(文件夹图标不再依赖内联 style,统一走主题色) */ +.file-tree .ant-menu-item-selected .ant-menu-item-icon, +.file-tree .folder-selected>.ant-menu-submenu-title .ant-menu-item-icon, +.file-tree .ant-menu-submenu-selected>.ant-menu-submenu-title .ant-menu-item-icon { + color: var(--link-color) !important; } .file-tree .ant-menu-item, diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index 73255fb..83ae353 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -1019,14 +1019,11 @@ function DocumentEditor() { if (!node.isLeaf) { const isOpen = openKeys.includes(node.key) - const folderIconStyle = isSelected ? { color: '#1890ff' } : undefined - // 目录 - 通过className和style控制选中样式 + // 目录 - 选中样式(背景/文字/图标)统一由 CSS 类控制,保证文字与图标同步高亮 return { key: node.key, label: labelContent, - icon: isOpen - ? - : , + icon: isOpen ? : , children: node.children ? convertTreeToMenuItems(node.children) : [], className: isSelected ? 'folder-selected' : '', onTitleClick: () => { diff --git a/frontend/src/pages/Document/DocumentPage.css b/frontend/src/pages/Document/DocumentPage.css index f1ae619..093586b 100644 --- a/frontend/src/pages/Document/DocumentPage.css +++ b/frontend/src/pages/Document/DocumentPage.css @@ -280,6 +280,121 @@ font-size: 14px; } +/* ===== 文件夹内容视图 ===== */ +.docs-folder-view { + padding: 4px 0 24px; +} + +.docs-folder-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + margin-bottom: 16px; + background: var(--bg-color-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + color: var(--text-color-secondary); + font-size: 13px; + min-width: 0; +} + +.docs-folder-path { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.docs-folder-path-icon { + flex: none; + color: #faad14; + font-size: 15px; +} + +.docs-folder-path-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: var(--text-color); +} + +.docs-folder-counts { + flex: none; + white-space: nowrap; +} + +.docs-folder-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 14px; +} + +.docs-folder-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 16px 18px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + cursor: pointer; + transition: all 0.2s ease; + min-width: 0; +} + +.docs-folder-item:hover { + border-color: var(--link-color); + box-shadow: var(--panel-shadow); + transform: translateY(-2px); +} + +.docs-folder-item:active { + transform: translateY(0); +} + +.docs-folder-item-icon { + font-size: 28px; + line-height: 1; + color: #faad14; +} + +.docs-folder-item-icon.md { + color: var(--link-color); +} + +.docs-folder-item-icon.pdf { + color: #f5222d; +} + +.docs-folder-item-name { + width: 100%; + display: inline-flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 500; + color: var(--text-color); + min-width: 0; +} + +.docs-folder-item-share { + flex: none; + color: var(--link-color); + font-size: 12px; +} + +.docs-folder-item-count { + font-size: 12px; + color: var(--text-color-secondary); +} + .markdown-body { font-size: 16px; line-height: 1.6; diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index eac1255..2e18d5a 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -128,7 +128,7 @@ function DocumentPage() { } } - const selectFolder = (folderPath, { syncUrl = false } = {}) => { + const selectFolder = (folderPath, { syncUrl = false, expandSelf = false } = {}) => { setSelectedFile('') setSelectedNodeKey(folderPath) setMarkdownContent('') @@ -136,11 +136,28 @@ function DocumentPage() { setViewMode('folder') expandParentFolders(`${folderPath}/placeholder`) + // 从内容区点击进入子文件夹时,同时展开自身,保证与左侧文件树同步 + if (expandSelf) { + setOpenKeys(prev => (prev.includes(folderPath) ? prev : [...prev, folderPath])) + } + if (syncUrl) { updateSelectedParam(folderPath, false) } } + // 获取文件夹内的子节点(与左侧文件树保持一致:文件夹 + md/pdf 文档) + const getFolderChildren = (folderPath) => { + const node = folderPath ? findNodeByKey(fileTree, folderPath) : null + if (!node || node.isLeaf) return { folders: [], files: [], node } + const children = node.children || [] + const folders = children.filter(c => !c.isLeaf) + const files = children.filter(c => + c.isLeaf && (c.title.toLowerCase().endsWith('.md') || c.title.toLowerCase().endsWith('.pdf')) + ) + return { folders, files, node } + } + const openDocumentPath = (filePath, { syncUrl = false } = {}) => { setSelectedFile(filePath) setSelectedNodeKey(filePath) @@ -1257,10 +1274,75 @@ function DocumentPage() { toolbarTarget={pdfToolbarTarget} /> ) : viewMode === 'folder' ? ( -
- - 已选择文件夹,请从左侧选择 Markdown 或 PDF 文件查看。 -
+ (() => { + const { folders, files, node } = getFolderChildren(selectedNodeKey) + if (!node || node.isLeaf) { + return ( +
+ + 已选择文件夹,请从左侧选择 Markdown 或 PDF 文件查看。 +
+ ) + } + const total = folders.length + files.length + return ( +
+
+
+ + {node.key || '根目录'} +
+ + {folders.length} 个文件夹 · {files.length} 个文档 + +
+ + {total === 0 ? ( + + ) : ( +
+ {folders.map(sub => ( +
selectFolder(sub.key, { syncUrl: true, expandSelf: true })} + title={sub.title} + > + + {sub.title} + {sub.children?.length || 0} 项 +
+ ))} + {files.map(file => { + const isPdf = file.title.toLowerCase().endsWith('.pdf') + const displayName = isPdf ? file.title : file.title.replace(/.md$/i, '') + return ( +
openDocumentPath(file.key, { syncUrl: true })} + title={file.title} + > + {isPdf + ? + : } + + {displayName} + {file.is_shared && } + + {isPdf ? 'PDF 文档' : 'Markdown 文档'} +
+ ) + })} +
+ )} +
+ ) + })() ) : isLargeMarkdown ? ( * { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + overflow: hidden; +} + +.project-stat-item { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.project-card-owner { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-width: 0; + max-width: 100%; +} + +.project-card-owner-avatar { + flex: none; + width: 22px; + height: 22px; + border-radius: 50%; + background: linear-gradient(135deg, #1677ff 0%, #69b1ff 100%); + color: #fff; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + user-select: none; +} + +.project-card-share .project-card-owner-avatar { + background: linear-gradient(135deg, #52c41a 0%, #95de64 100%); +} + +.project-card-owner-name { + font-size: 12px; + font-weight: 500; + color: var(--text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +/* 项目角色徽章(重新设计:药丸式彩色徽章 + 角色图标) */ +.project-card-role-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + line-height: 16px; + font-weight: 500; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 所有者 / 管理者:金色徽章 */ +.project-card-role-badge.role-owner, +.project-card-role-badge.role-admin { + background: rgba(250, 173, 20, 0.16); + color: #d48806; +} + +body.dark .project-card-role-badge.role-owner, +body.dark .project-card-role-badge.role-admin { + background: rgba(250, 173, 20, 0.22); + color: #ffc53d; +} + +/* 编辑者:蓝色徽章 */ +.project-card-role-badge.role-editor { + background: rgba(22, 119, 255, 0.12); + color: var(--link-color); +} + +body.dark .project-card-role-badge.role-editor { + background: rgba(23, 125, 220, 0.25); + color: #4c9be8; +} + +/* 查看者:灰色徽章 */ +.project-card-role-badge.role-viewer { + background: var(--item-hover-bg); + color: var(--text-color-secondary); +} + +/* ========== 我的项目卡片:蓝色管理风 ========== */ +.project-card-my { + text-align: left; + border-top: 3px solid var(--link-color); +} + +.project-card-my .ant-card-actions { + background: var(--bg-color-secondary); +} + +/* ========== 参与的项目卡片:绿色协作风 ========== */ +.project-card-share { + text-align: left; + border-top: 3px solid #52c41a; +} + +.project-card-share .ant-card-actions { + background: rgba(82, 196, 26, 0.05); +} + +.project-card-share .ant-card-actions li span { + color: #52c41a; +} + +/* ========== 搜索结果卡片(保持旧版居中风格) ========== */ +.project-card-icon { + margin-bottom: 16px; + text-align: center; } /* 知识库向量化弹窗:未配置向量模型时的提示条 */ @@ -106,19 +305,28 @@ body.dark .kb-warning-banner { margin-top: 8px; } -/* 圆点分页指示器样式 */ +/* ========== 圆点分页指示器(对称布局) ========== */ .dot-pagination.ant-pagination { display: flex; align-items: center; + justify-content: center; + gap: 2px; + flex-wrap: nowrap; } -.dot-pagination .ant-pagination-item { +.dot-pagination .ant-pagination-item, +.dot-pagination .ant-pagination-prev, +.dot-pagination .ant-pagination-next { border: none !important; background: transparent !important; - min-width: 16px !important; - height: 16px !important; - line-height: 16px !important; - margin: 0 4px !important; + margin: 0 !important; + min-width: 24px !important; + height: 24px !important; + line-height: 24px !important; + display: inline-flex !important; + align-items: center; + justify-content: center; + vertical-align: middle; } .dot-pagination .ant-pagination-item a { @@ -132,19 +340,67 @@ body.dark .kb-warning-banner { border-radius: 50%; background-color: var(--text-color-secondary); opacity: 0.3; - margin: 4px auto; transition: all 0.3s; } .dot-pagination .ant-pagination-item-active .pagination-dot { background-color: var(--link-color); opacity: 1; - transform: scale(1.2); + transform: scale(1.25); } -.dot-pagination .ant-pagination-prev, -.dot-pagination .ant-pagination-next { +.dot-pagination .ant-pagination-jump-prev, +.dot-pagination .ant-pagination-jump-next { + border: none !important; + background: transparent !important; + margin: 0 !important; min-width: 24px !important; height: 24px !important; line-height: 24px !important; + display: inline-flex !important; + align-items: center; + justify-content: center; + color: var(--text-color-secondary); + font-size: 12px; +} + +.dot-pagination .ant-pagination-jump-prev .ant-pagination-item-container, +.dot-pagination .ant-pagination-jump-next .ant-pagination-item-container { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.dot-pagination .ant-pagination-prev .ant-pagination-item-link, +.dot-pagination .ant-pagination-next .ant-pagination-item-link { + border: none !important; + background: transparent !important; + color: var(--text-color-secondary); + font-size: 12px; + line-height: 1; + height: 100%; + width: 100%; + display: inline-flex !important; + align-items: center; + justify-content: center; + border-radius: 6px; + padding: 0; + transition: all 0.2s; +} + +.dot-pagination .ant-pagination-prev:hover .ant-pagination-item-link, +.dot-pagination .ant-pagination-next:hover .ant-pagination-item-link { + color: var(--link-color); + background: var(--item-hover-bg) !important; +} + +.dot-pagination .ant-pagination-prev:active .ant-pagination-item-link, +.dot-pagination .ant-pagination-next:active .ant-pagination-item-link { + background: var(--item-hover-bg) !important; +} + +.dot-pagination .ant-pagination-disabled .ant-pagination-item-link { + color: var(--text-color-secondary) !important; + opacity: 0.35; + cursor: not-allowed; } diff --git a/frontend/src/pages/ProjectList/ProjectList.jsx b/frontend/src/pages/ProjectList/ProjectList.jsx index e9d1ec7..13ded7e 100644 --- a/frontend/src/pages/ProjectList/ProjectList.jsx +++ b/frontend/src/pages/ProjectList/ProjectList.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd' -import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined } from '@ant-design/icons' +import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons' import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project' import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share' import { getUserList } from '@/api/users' @@ -9,6 +9,7 @@ import { searchDocuments } from '@/api/search' import ListActionBar from '@/components/ListActionBar/ListActionBar' import Toast from '@/components/Toast/Toast' import useProjectKnowledge from './useProjectKnowledge' +import useUserStore from '@/stores/userStore' import './ProjectList.css' function ProjectList({ type = 'my' }) { @@ -36,9 +37,62 @@ function ProjectList({ type = 'my' }) { const [transferModalVisible, setTransferModalVisible] = useState(false) const [transferForm] = Form.useForm() const navigate = useNavigate() + const { user: currentUser } = useUserStore() const [currentPage, setCurrentPage] = useState(1) const pageSize = 8 + // 参与项目角色 -> 显示名称 + const projectRoleLabel = (role) => { + const roleMap = { admin: '管理者', editor: '编辑者', viewer: '查看者' } + return roleMap[role] || role || '查看者' + } + + // 格式化日期 + const formatDate = (dateStr) => { + if (!dateStr) return '' + try { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleDateString('zh-CN') + } catch { + return '' + } + } + + // 卡片归属用户显示名(我的项目 -> 当前用户;参与项目 -> 项目所有者) + const cardOwnerName = (project) => { + if (type === 'my') { + return currentUser?.nickname || currentUser?.username || '我' + } + return project.owner_nickname || project.owner_name || '未知' + } + + // 卡片归属用户头像首字符 + const cardOwnerInitial = (project) => { + const name = cardOwnerName(project) + return (name || '?').charAt(0).toUpperCase() + } + + // 卡片角色显示名(我的项目 -> 所有者;参与项目 -> 我的角色) + const cardRoleLabel = (project) => { + if (type === 'my') return '所有者' + return projectRoleLabel(project.user_role) + } + + // 卡片角色徽章样式类 + const cardRoleClass = (project) => { + if (type === 'my') return 'role-owner' + const roleMap = { admin: 'role-admin', editor: 'role-editor', viewer: 'role-viewer' } + return roleMap[project.user_role] || 'role-viewer' + } + + // 卡片角色徽章图标 + const cardRoleIcon = (project) => { + if (type === 'my' || project.user_role === 'admin') return + if (project.user_role === 'editor') return + return + } + const { kbModalVisible, progress, @@ -620,7 +674,7 @@ function ProjectList({ type = 'my' }) { handleOpenProject(project.id)} actions={type === 'my' ? [ handleEdit(e, project)} />, @@ -628,30 +682,53 @@ function ProjectList({ type = 'my' }) { handleKnowledge(e, project)} />, handleMembers(e, project)} />, ] : [ - , + , ]} > {/* 公开项目标识 */} {project.is_public === 1 && (
公开
)} -
- -
-

{project.name}

-

{project.description || '暂无描述'}

-
- 文档数: {project.doc_count || 0} - {type === 'share' && project.owner_name && ( - - 所有者: {project.owner_nickname || project.owner_name} + + {/* 统一的卡片骨架:标题 + 描述(主要信息)+ 底部统计/归属/角色 */} +
+
+ + {type === 'my' ? : } - )} - {type === 'share' && project.user_role && ( - - 角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'} - - )} +

{project.name}

+
+

{project.description || '暂无描述'}

+ {/* 归属人信息 + 项目信息:单行展示 */} +
+ {type === 'my' ? ( + <> + + {project.doc_count || 0} 文档 + + + {formatDate(project.created_at) || '—'} + + + {project.member_count ?? 0} 人参与 + + + ) : ( + <> + + {project.doc_count || 0} 文档 + + + {cardOwnerInitial(project)} + {cardOwnerName(project)} + + + {cardRoleIcon(project)} + {cardRoleLabel(project)} + + + )} +