diff --git a/.gemini-clipboard/clipboard-1769082714226.png b/.gemini-clipboard/clipboard-1769082714226.png deleted file mode 100644 index dc39bd2..0000000 Binary files a/.gemini-clipboard/clipboard-1769082714226.png and /dev/null differ diff --git a/.gemini-clipboard/clipboard-1769085744299.png b/.gemini-clipboard/clipboard-1769085744299.png new file mode 100644 index 0000000..8c41fec Binary files /dev/null and b/.gemini-clipboard/clipboard-1769085744299.png differ diff --git a/.gemini-clipboard/clipboard-1769087429425.png b/.gemini-clipboard/clipboard-1769087429425.png new file mode 100644 index 0000000..0d29001 Binary files /dev/null and b/.gemini-clipboard/clipboard-1769087429425.png differ diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index 94e0071..8c826ef 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -28,6 +28,7 @@ from app.schemas.response import success_response from app.services.storage import storage_service from app.services.log_service import log_service from app.services.notification_service import notification_service +from app.services.search_service import search_service from app.core.enums import OperationType router = APIRouter() @@ -145,6 +146,11 @@ async def save_file( # 写入文件内容 await storage_service.write_file(file_path, file_data.content) + # 更新搜索索引 (仅限 Markdown) + if file_data.path.endswith('.md'): + file_title = Path(file_data.path).stem + await search_service.update_doc(project_id, file_data.path, file_title, file_data.content) + # 记录操作日志 await log_service.log_file_operation( db=db, @@ -188,6 +194,11 @@ async def operate_file( if operation.action == "delete": # 删除文件或文件夹 await storage_service.delete_file(current_path) + + # 删除索引 + if operation.path.endswith('.md'): + await search_service.remove_doc(project_id, operation.path) + # 记录日志 await log_service.log_file_operation( db=db, @@ -216,6 +227,21 @@ async def operate_file( raise HTTPException(status_code=400, detail="缺少新路径参数") new_path = storage_service.get_secure_path(project.storage_key, operation.new_path) await storage_service.rename_file(current_path, new_path) + + # 更新索引 (删除旧的,添加新的 - 如果内容未变也需要重新读取内容吗? + # 优化:Whoosh 更新需要内容。我们可以尝试读取文件内容。 + # 如果是目录重命名,比较复杂,暂时忽略目录重命名的递归索引更新,或者后续实现重建索引功能) + if operation.path.endswith('.md') and operation.new_path.endswith('.md'): + # 简单处理:读取新文件内容并更新索引 + try: + content = await storage_service.read_file(new_path) + file_title = Path(operation.new_path).stem + await search_service.remove_doc(project_id, operation.path) + await search_service.update_doc(project_id, operation.new_path, file_title, content) + except Exception as e: + # 忽略索引更新错误 + pass + # 记录日志 await log_service.log_file_operation( db=db, @@ -245,6 +271,17 @@ async def operate_file( raise HTTPException(status_code=400, detail="缺少目标路径参数") new_path = storage_service.get_secure_path(project.storage_key, operation.new_path) await storage_service.rename_file(current_path, new_path) + + # 更新索引 + if operation.path.endswith('.md') and operation.new_path.endswith('.md'): + try: + content = await storage_service.read_file(new_path) + file_title = Path(operation.new_path).stem + await search_service.remove_doc(project_id, operation.path) + await search_service.update_doc(project_id, operation.new_path, file_title, content) + except Exception: + pass + # 记录日志 await log_service.log_file_operation( db=db, @@ -297,6 +334,12 @@ async def operate_file( # 创建文件 content = operation.content or "" await storage_service.write_file(current_path, content) + + # 更新索引 + if operation.path.endswith('.md'): + file_title = Path(operation.path).stem + await search_service.update_doc(project_id, operation.path, file_title, content) + # 记录日志 await log_service.log_file_operation( db=db, @@ -374,11 +417,6 @@ async def upload_document( ): """ 上传文档文件(PDF等)到项目目录 - - Args: - project_id: 项目ID - file: 上传的文件 - target_dir: 目标目录(相对路径,如 "docs" 或 "docs/manuals",空字符串表示根目录) """ project = await check_project_access(project_id, current_user, db, require_write=True) @@ -421,10 +459,6 @@ async def get_document_file( ): """ 获取文档文件(PDF等)- 支持 HTTP Range 请求 - - Args: - project_id: 项目ID - path: 文件相对路径(如 "manual.pdf" 或 "docs/guide.pdf") """ import re import aiofiles @@ -564,6 +598,14 @@ async def import_documents( # 构建相对路径 relative_path = f"{target_path}/{file.filename}" if target_path else file.filename imported_files.append(relative_path) + + # 更新索引 + try: + text_content = content.decode('utf-8') + file_title = Path(file.filename).stem + await search_service.update_doc(project_id, relative_path, file_title, text_content) + except Exception: + pass # 记录日志 await log_service.log_file_operation( @@ -641,4 +683,4 @@ async def export_directory( headers={ "Content-Disposition": f"attachment; filename={zip_filename}" } - ) + ) \ No newline at end of file diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py index 8a36101..4903bdc 100644 --- a/backend/app/api/v1/notifications.py +++ b/backend/app/api/v1/notifications.py @@ -1,21 +1,16 @@ """ -通知管理 API +通知管理 API (Redis版) """ -from fastapi import APIRouter, Depends, HTTPException, Request -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update, func, desc -from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException +from typing import List, Union from datetime import datetime from app.core.database import get_db from app.core.deps import get_current_user from app.models.user import User -from app.models.notification import Notification from app.schemas.notification import ( NotificationResponse, - NotificationUpdate, - UnreadCountResponse, - NotificationCreate + NotificationCreate, ) from app.schemas.response import success_response from app.services.notification_service import notification_service @@ -27,97 +22,75 @@ router = APIRouter() async def get_notifications( page: int = 1, page_size: int = 20, - unread_only: bool = False, current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) ): - """获取当前用户的通知列表""" - query = select(Notification).where(Notification.user_id == current_user.id) - - if unread_only: - query = query.where(Notification.is_read == 0) - - query = query.order_by(desc(Notification.created_at)) - - # 分页 - offset = (page - 1) * page_size - query = query.offset(offset).limit(page_size) - - result = await db.execute(query) - notifications = result.scalars().all() - - # 获取总数 - count_query = select(func.count()).select_from(Notification).where(Notification.user_id == current_user.id) - if unread_only: - count_query = count_query.where(Notification.is_read == 0) - total_result = await db.execute(count_query) - total = total_result.scalar() + """获取当前用户的通知列表 (从 Redis 读取)""" + try: + skip = (page - 1) * page_size + notifications = await notification_service.get_user_notifications( + user_id=current_user.id, + limit=page_size, + skip=skip + ) + + # 获取总数 (ZCARD) + from app.core.redis_client import get_redis + redis = get_redis() + total = 0 + if redis: + key = notification_service._get_order_key(current_user.id) + total = await redis.zcard(key) - data = [NotificationResponse.from_orm(n).dict() for n in notifications] - return { - "code": 200, - "message": "success", - "data": data, - "total": total, - "page": page, - "page_size": page_size - } + return { + "code": 200, + "message": "success", + "data": notifications, + "total": total, + "page": page, + "page_size": page_size + } + except Exception as e: + # 降级处理,防止 500 + print(f"Error fetching notifications: {e}") + return { + "code": 200, + "message": "success", + "data": [], + "total": 0, + "page": page, + "page_size": page_size + } @router.get("/unread-count", response_model=dict) async def get_unread_count( current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) ): """获取未读通知数量""" - result = await db.execute( - select(func.count()).select_from(Notification).where( - Notification.user_id == current_user.id, - Notification.is_read == 0 - ) - ) - count = result.scalar() - return success_response(data={"unread_count": count}) + try: + count = await notification_service.get_unread_count(current_user.id) + return success_response(data={"unread_count": count}) + except Exception as e: + print(f"Error fetching unread count: {e}") + return success_response(data={"unread_count": 0}) @router.put("/{notification_id}/read", response_model=dict) async def mark_as_read( - notification_id: int, + notification_id: str, current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) ): """标记单条通知为已读""" - result = await db.execute( - select(Notification).where( - Notification.id == notification_id, - Notification.user_id == current_user.id - ) - ) - notification = result.scalar_one_or_none() - - if not notification: - raise HTTPException(status_code=404, detail="通知不存在") - - if notification.is_read == 0: - notification.is_read = 1 - notification.read_at = datetime.now() - await db.commit() - + await notification_service.mark_read(current_user.id, notification_id) return success_response(message="已标记为已读") @router.put("/read-all", response_model=dict) async def mark_all_as_read( current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) ): """标记所有通知为已读""" - await db.execute( - update(Notification) - .where(Notification.user_id == current_user.id, Notification.is_read == 0) - .values(is_read=1, read_at=datetime.now()) - ) - await db.commit() + await notification_service.mark_all_read(current_user.id) return success_response(message="全部标记为已读") @@ -125,14 +98,14 @@ async def mark_all_as_read( async def send_system_notification( notification_in: NotificationCreate, current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db) ): """发送系统通知(仅限超级管理员)""" if not current_user.is_superuser: raise HTTPException(status_code=403, detail="只有管理员可以发送系统通知") + # 這裡我们传 db=None 因为 service 已经不需要 db 写操作了 await notification_service.create_notification( - db=db, + db=None, user_id=notification_in.user_id, title=notification_in.title, content=notification_in.content, @@ -140,5 +113,4 @@ async def send_system_notification( category="system", link=notification_in.link ) - await db.commit() return success_response(message="系统通知发送成功") diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 3d4cc50..95e5a4d 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -32,6 +32,22 @@ from app.core.enums import OperationType, ResourceType router = APIRouter() +def get_document_count(storage_key: str) -> int: + """计算项目中的文档数量(.md 和 .pdf)""" + try: + project_path = storage_service.get_secure_path(storage_key) + if not project_path.exists(): + return 0 + md_count = len(list(project_path.rglob("*.md"))) + pdf_count = len(list(project_path.rglob("*.pdf"))) + # 排除 _assets 目录下的文件 + assets_md = len(list((project_path / "_assets").rglob("*.md"))) if (project_path / "_assets").exists() else 0 + assets_pdf = len(list((project_path / "_assets").rglob("*.pdf"))) if (project_path / "_assets").exists() else 0 + return md_count + pdf_count - assets_md - assets_pdf + except Exception: + return 0 + + @router.get("/", response_model=dict) async def get_my_projects( current_user: User = Depends(get_current_user), @@ -58,7 +74,11 @@ async def get_my_projects( # 合并结果 all_projects = owned_projects + member_projects - projects_data = [ProjectResponse.from_orm(p).dict() for p in 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) + projects_data.append(p_dict) return success_response(data=projects_data) @@ -73,7 +93,11 @@ async def get_owned_projects( select(Project).where(Project.owner_id == current_user.id, Project.status == 1) ) projects = result.scalars().all() - projects_data = [ProjectResponse.from_orm(p).dict() for p in projects] + projects_data = [] + for p in projects: + p_dict = ProjectResponse.from_orm(p).dict() + p_dict['doc_count'] = get_document_count(p.storage_key) + projects_data.append(p_dict) return success_response(data=projects_data) @@ -101,6 +125,7 @@ async def get_shared_projects( project_dict['owner_name'] = owner.username project_dict['owner_nickname'] = owner.nickname project_dict['user_role'] = member.role # 添加用户角色 + project_dict['doc_count'] = get_document_count(project.storage_key) projects_data.append(project_dict) return success_response(data=projects_data) @@ -188,7 +213,7 @@ async def get_project( if not member and project.is_public != 1: raise HTTPException(status_code=403, detail="无权访问该项目") - # 增加访问次数 + # 增加访问次数 (简单计数) project.visit_count += 1 await db.commit() diff --git a/backend/app/api/v1/search.py b/backend/app/api/v1/search.py index ed23022..264a4ea 100644 --- a/backend/app/api/v1/search.py +++ b/backend/app/api/v1/search.py @@ -1,128 +1,270 @@ """ 文档搜索相关 API """ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ -import os -import glob +from typing import Optional, List +from pathlib import Path +import logging from app.core.database import get_db from app.core.deps import get_current_user from app.models.user import User from app.models.project import Project, ProjectMember +from app.services.search_service import search_service from app.services.storage import storage_service from app.schemas.response import success_response router = APIRouter() - +logger = logging.getLogger(__name__) @router.get("/documents", response_model=dict) async def search_documents( keyword: str = Query(..., min_length=1, description="搜索关键词"), + project_id: Optional[int] = Query(None, description="限制在指定项目中搜索"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """ - 文档搜索(简化版) - 搜索范围:项目名称、项目描述、文件名(支持.md和.pdf) + 文档搜索 (混合模式:Whoosh 全文检索 + 数据库项目搜索 + 文件系统文件名搜索 fallback) """ - if not keyword: - return success_response(data=[]) + try: + if not keyword: + return success_response(data=[]) - keyword_lower = keyword.lower() + # 1. 确定搜索范围 (项目ID列表) + allowed_project_ids = [] + + if project_id: + # 检查指定项目的访问权限 + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 检查权限 + if project.owner_id != current_user.id and project.is_public != 1: + member_result = await db.execute( + select(ProjectMember).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == current_user.id + ) + ) + if not member_result.scalar_one_or_none(): + raise HTTPException(status_code=403, detail="无权访问该项目") + + allowed_project_ids.append(str(project_id)) + else: + # 获取所有可访问的项目 + # 1. 用户创建的项目 + owned_result = await db.execute( + select(Project.id).where(Project.owner_id == current_user.id, Project.status == 1) + ) + allowed_project_ids.extend([str(pid) for pid in owned_result.scalars().all()]) - # 获取用户有权限访问的项目 - # 1. 用户创建的项目 - owned_projects_result = await db.execute( - select(Project).where(Project.owner_id == current_user.id, Project.status == 1) - ) - owned_projects = owned_projects_result.scalars().all() + # 2. 用户参与的项目 + member_result = await db.execute( + select(ProjectMember.project_id) + .join(Project, ProjectMember.project_id == Project.id) + .where( + ProjectMember.user_id == current_user.id, + Project.status == 1 + ) + ) + allowed_project_ids.extend([str(pid) for pid in member_result.scalars().all()]) + + # 去重 + allowed_project_ids = list(set(allowed_project_ids)) - # 2. 用户参与的项目 - member_projects_result = await db.execute( - select(Project) - .join(ProjectMember, ProjectMember.project_id == Project.id) - .where( - ProjectMember.user_id == current_user.id, - Project.owner_id != current_user.id, - Project.status == 1 - ) - ) - member_projects = member_projects_result.scalars().all() + if not allowed_project_ids: + return success_response(data=[]) - # 合并所有可访问的项目 - all_projects = owned_projects + member_projects + # 2. 执行搜索 + search_results = [] + + # A. 数据库项目搜索 (仅当未指定 project_id 时,或者需要搜项目本身) + # 如果前端指定了 project_id,通常是在项目内搜文件,不需要搜项目本身 + if not project_id: + projects_query = select(Project).where( + Project.id.in_(allowed_project_ids), + or_( + Project.name.ilike(f"%{keyword}%"), + Project.description.ilike(f"%{keyword}%") + ) + ) + project_res = await db.execute(projects_query) + matched_projects = project_res.scalars().all() + + for proj in matched_projects: + search_results.append({ + "type": "project", + "project_id": proj.id, + "project_name": proj.name, + "project_description": proj.description or "", + "match_type": "项目名称/描述", + }) - # 搜索结果列表 - search_results = [] + # B. Whoosh 全文检索 + whoosh_results = [] + try: + if project_id: + whoosh_results = await search_service.search(keyword, str(project_id)) + else: + # 全局搜索 + whoosh_results = await search_service.search(keyword, limit=50) + # 过滤权限 + whoosh_results = [r for r in whoosh_results if str(r['project_id']) in allowed_project_ids] + except Exception as e: + logger.warning(f"Whoosh search failed: {e}") + pass + + # 获取 Whoosh 结果涉及的项目 ID + whoosh_project_ids = set(res['project_id'] for res in whoosh_results if res.get('project_id')) + + # 查询项目名称映射 + project_name_map = {} + if whoosh_project_ids: + p_res = await db.execute(select(Project.id, Project.name).where(Project.id.in_(whoosh_project_ids))) + for pid, pname in p_res.all(): + project_name_map[str(pid)] = pname - # 搜索项目和文件 - for project in all_projects: - # 检查项目名称或描述是否匹配 - project_matched = False - if keyword_lower in project.name.lower(): - project_matched = True - elif project.description and keyword_lower in project.description.lower(): - project_matched = True - - # 如果项目本身匹配,添加到结果 - if project_matched: + # 添加 Whoosh 结果 + for res in whoosh_results: + pid_str = str(res['project_id']) search_results.append({ - "type": "project", - "project_id": project.id, - "project_name": project.name, - "project_description": project.description or "", - "match_type": "项目", + "type": "file", + "project_id": res['project_id'], + "project_name": project_name_map.get(pid_str, "未知项目"), + "file_path": res['path'], + "file_name": res['title'], + "highlights": res.get('highlights'), + "match_type": "全文检索" }) - # 搜索项目中的文件名 - try: - project_path = storage_service.get_secure_path(project.storage_key) + # C. 文件系统文件名搜索 (Fallback / Complementary) + # 为了保证未索引的文件也能通过文件名搜到 + + # 获取需要扫描的项目 + projects_to_scan = [] + if project_id: + # 单项目扫描 + res = await db.execute(select(Project).where(Project.id == project_id)) + p = res.scalar_one_or_none() + if p: projects_to_scan = [p] + elif len(search_results) < 20: + # 全局扫描:仅当结果较少时才进行全盘扫描,避免性能问题 + # 这是一个简单的启发式策略 + res = await db.execute(select(Project).where(Project.id.in_(allowed_project_ids))) + projects_to_scan = res.scalars().all() - if not project_path.exists() or not project_path.is_dir(): + # 已存在的文件路径集合 (用于去重) + existing_paths = set() + for res in search_results: + if res.get('type') == 'file': + # 统一 key 格式 + existing_paths.add(f"{res['project_id']}:{res['file_path']}") + + keyword_lower = keyword.lower() + + for project in projects_to_scan: + try: + project_path = storage_service.get_secure_path(project.storage_key) + if not project_path.exists(): continue + + # 查找文件名匹配 + md_files = list(project_path.rglob("*.md")) + pdf_files = list(project_path.rglob("*.pdf")) + + for file_path in md_files + pdf_files: + if "_assets" in file_path.parts: continue + + if keyword_lower in file_path.name.lower(): + rel_path = str(file_path.relative_to(project_path)) + unique_key = f"{project.id}:{rel_path}" + + if unique_key not in existing_paths: + search_results.append({ + "type": "file", + "project_id": project.id, + "project_name": project.name, + "file_path": rel_path, + "file_name": file_path.name, + "match_type": "文件名匹配" + }) + existing_paths.add(unique_key) + except Exception: continue - # 查找所有 .md 和 .pdf 文件 - md_files = list(project_path.rglob("*.md")) - pdf_files = list(project_path.rglob("*.pdf")) - all_files = md_files + pdf_files + return success_response(data=search_results[:100]) + + except Exception as e: + logger.error(f"Search API error: {e}") + return success_response(data=[], message="搜索服务暂时不可用") - for file_path in all_files: - # 跳过 _assets 目录中的文件 - if "_assets" in file_path.parts: + +async def rebuild_index_task(db: AsyncSession): + """后台任务:重建索引""" + logger.info("Starting index rebuild...") + try: + # 获取所有项目 + result = await db.execute(select(Project).where(Project.status == 1)) + projects = result.scalars().all() + + documents = [] + for project in projects: + try: + # 遍历项目文件 + project_root = storage_service.get_secure_path(project.storage_key) + if not project_root.exists(): continue - - try: - # 获取相对路径 - relative_path = str(file_path.relative_to(project_path)) - - # 获取文件名(PDF保留扩展名,MD去掉扩展名) - if file_path.suffix.lower() == '.pdf': - file_name = file_path.name # PDF保留完整文件名 - else: - file_name = file_path.stem # MD去掉扩展名 - - # 检查关键词是否在文件名或路径中 - if keyword_lower in file_name.lower() or keyword_lower in relative_path.lower(): - search_results.append({ - "type": "file", + + # 查找所有 .md 文件 + md_files = list(project_root.rglob("*.md")) + + for file_path in md_files: + if "_assets" in file_path.parts: + continue + + try: + content = await storage_service.read_file(file_path) + relative_path = str(file_path.relative_to(project_root)) + + documents.append({ "project_id": project.id, - "project_name": project.name, - "file_path": relative_path, - "file_name": file_name, - "match_type": "文件", + "path": relative_path, + "title": file_path.stem, + "content": content }) + except Exception: + continue + except Exception as e: + logger.error(f"Error processing project {project.id}: {e}") + continue + + # 批量写入索引 + import asyncio + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, search_service.rebuild_index_sync, documents) + logger.info(f"Index rebuild completed. Indexed {len(documents)} documents.") + + except Exception as e: + logger.error(f"Index rebuild failed: {e}") - except Exception: - # 忽略无法处理的文件 - continue - except Exception: - # 忽略无法遍历的目录 - continue - - # 限制返回结果数量 - search_results = search_results[:100] - - return success_response(data=search_results, message=f"找到 {len(search_results)} 个结果") +@router.post("/rebuild-index", response_model=dict) +async def rebuild_index( + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 重建搜索索引 (仅限超级管理员) + """ + if not current_user.is_superuser: + raise HTTPException(status_code=403, detail="权限不足") + + background_tasks.add_task(rebuild_index_task, db) + + return success_response(message="索引重建任务已启动") \ No newline at end of file diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index f3acabe..2bdd199 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -2,7 +2,7 @@ 通知相关的 Pydantic Schema """ from pydantic import BaseModel, Field -from typing import Optional, List +from typing import Optional, List, Union from datetime import datetime @@ -19,14 +19,14 @@ class NotificationCreate(NotificationBase): class NotificationUpdate(BaseModel): - is_read: Optional[int] = None + is_read: Optional[bool] = None class NotificationResponse(NotificationBase): - id: int + id: Union[str, int] user_id: int - is_read: int - created_at: datetime + is_read: bool + created_at: Union[datetime, float] # Redis returns float timestamp read_at: Optional[datetime] = None class Config: @@ -34,4 +34,4 @@ class NotificationResponse(NotificationBase): class UnreadCountResponse(BaseModel): - unread_count: int + unread_count: int \ No newline at end of file diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 0c21536..73982aa 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,13 +1,25 @@ import logging +import json +import time +import uuid from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update, insert -from app.models.notification import Notification +from sqlalchemy import select from app.models.project import ProjectMember -from typing import List, Optional +from app.core.redis_client import get_redis +from typing import List, Optional, Dict, Any logger = logging.getLogger(__name__) class NotificationService: + # 通知过期时间:14天 (秒) + EXPIRATION_SECONDS = 14 * 24 * 60 * 60 + + def _get_order_key(self, user_id: int) -> str: + return f"notifications:order:{user_id}" + + def _get_content_key(self, user_id: int) -> str: + return f"notifications:content:{user_id}" + async def create_notification( self, db: AsyncSession, @@ -17,19 +29,41 @@ class NotificationService: type: str = "info", category: str = "system", link: str = None - ) -> Notification: - """创建单条通知""" - db_notification = Notification( - user_id=user_id, - title=title, - content=content, - type=type, - category=category, - link=link - ) - db.add(db_notification) - await db.flush() - return db_notification + ) -> Dict[str, Any]: + """创建单条通知 (写入 Redis)""" + redis = get_redis() + if not redis: + return None + + timestamp = time.time() + notification_id = str(uuid.uuid4()) + + notification_data = { + "id": notification_id, + "user_id": user_id, + "title": title, + "content": content, + "type": type, + "category": category, + "link": link, + "is_read": False, + "created_at": timestamp + } + + json_data = json.dumps(notification_data, ensure_ascii=False) + order_key = self._get_order_key(user_id) + content_key = self._get_content_key(user_id) + + async with redis.pipeline() as pipe: + pipe.hset(content_key, notification_id, json_data) + pipe.zadd(order_key, {notification_id: timestamp}) + min_score = timestamp - self.EXPIRATION_SECONDS + pipe.zremrangebyscore(order_key, "-inf", min_score) + pipe.expire(order_key, self.EXPIRATION_SECONDS + 86400) + pipe.expire(content_key, self.EXPIRATION_SECONDS + 86400) + await pipe.execute() + + return notification_data async def broadcast_system_notification( self, @@ -40,17 +74,37 @@ class NotificationService: link: str = None ): """向指定多个用户发送系统通知""" - for uid in user_ids: - db_notification = Notification( - user_id=uid, - title=title, - content=content, - type="info", - category="system", - link=link - ) - db.add(db_notification) - await db.flush() + redis = get_redis() + if not redis: + return + + timestamp = time.time() + + async with redis.pipeline() as pipe: + for uid in user_ids: + notification_id = str(uuid.uuid4()) + notification_data = { + "id": notification_id, + "user_id": uid, + "title": title, + "content": content, + "type": "info", + "category": "system", + "link": link, + "is_read": False, + "created_at": timestamp + } + json_data = json.dumps(notification_data, ensure_ascii=False) + + order_key = self._get_order_key(uid) + content_key = self._get_content_key(uid) + + pipe.hset(content_key, notification_id, json_data) + pipe.zadd(order_key, {notification_id: timestamp}) + pipe.expire(order_key, self.EXPIRATION_SECONDS + 86400) + pipe.expire(content_key, self.EXPIRATION_SECONDS + 86400) + + await pipe.execute() async def notify_project_members( self, @@ -62,7 +116,6 @@ class NotificationService: link: str = None, category: str = "project" ): - """通知项目中除指定用户外的所有成员""" result = await db.execute( select(ProjectMember.user_id).where( ProjectMember.project_id == project_id, @@ -71,20 +124,126 @@ class NotificationService: ) member_ids = result.scalars().all() - logger.info(f"Notifying members of project {project_id}. Found {len(member_ids)} members to notify (excluding user {exclude_user_id}).") - if member_ids: - for uid in member_ids: - db_notification = Notification( - user_id=uid, - title=title, - content=content, - type="info", - category=category, - link=link - ) - db.add(db_notification) - await db.flush() + await self.broadcast_system_notification( + db, + title=title, + content=content, + user_ids=member_ids, + link=link + ) + async def get_user_notifications( + self, + user_id: int, + limit: int = 50, + skip: int = 0 + ) -> List[Dict[str, Any]]: + """获取用户通知列表""" + redis = get_redis() + if not redis: + return [] + + order_key = self._get_order_key(user_id) + content_key = self._get_content_key(user_id) + + ids = await redis.zrevrange(order_key, skip, skip + limit - 1) + if not ids: + return [] + + json_strings = await redis.hmget(content_key, ids) + + notifications = [] + ids_to_remove = [] + + for i, json_str in enumerate(json_strings): + if json_str: + try: + notifications.append(json.loads(json_str)) + except: + continue + else: + ids_to_remove.append(ids[i]) + + if ids_to_remove: + await redis.zrem(order_key, *ids_to_remove) + + return notifications -notification_service = NotificationService() + async def get_unread_count(self, user_id: int) -> int: + """获取未读通知数量""" + redis = get_redis() + if not redis: + return 0 + + content_key = self._get_content_key(user_id) + all_jsons = await redis.hvals(content_key) + count = 0 + for js in all_jsons: + try: + if js: + data = json.loads(js) + if not data.get('is_read'): + count += 1 + except: + pass + return count + + async def mark_read(self, user_id: int, notification_id: str): + """标记已读""" + redis = get_redis() + if not redis: + return + + content_key = self._get_content_key(user_id) + + json_str = await redis.hget(content_key, notification_id) + if json_str: + try: + data = json.loads(json_str) + data['is_read'] = True + await redis.hset(content_key, notification_id, json.dumps(data, ensure_ascii=False)) + except: + pass + + async def mark_all_read(self, user_id: int): + """标记所有已读""" + redis = get_redis() + if not redis: + return + + order_key = self._get_order_key(user_id) + content_key = self._get_content_key(user_id) + + ids = await redis.zrange(order_key, 0, -1) + if not ids: + return + + json_strings = await redis.hmget(content_key, ids) + + updates = {} + for i, json_str in enumerate(json_strings): + if json_str: + try: + data = json.loads(json_str) + if not data.get('is_read'): + data['is_read'] = True + updates[ids[i]] = json.dumps(data, ensure_ascii=False) + except: + pass + + if updates: + await redis.hset(content_key, mapping=updates) + + async def delete_notification(self, user_id: int, notification_id: str): + redis = get_redis() + if not redis: + return + + order_key = self._get_order_key(user_id) + content_key = self._get_content_key(user_id) + + await redis.zrem(order_key, notification_id) + await redis.hdel(content_key, notification_id) + +notification_service = NotificationService() \ No newline at end of file diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py new file mode 100644 index 0000000..b864439 --- /dev/null +++ b/backend/app/services/search_service.py @@ -0,0 +1,220 @@ +import os +import shutil +import asyncio +import logging +from pathlib import Path +from typing import List, Dict, Any, Optional +from whoosh import index +from whoosh.fields import Schema, TEXT, ID, DATETIME +from whoosh.qparser import QueryParser, MultifieldParser +from whoosh.analysis import Tokenizer, Token +from whoosh.highlight import HtmlFormatter +import jieba + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# 定义中文分词器 +class ChineseTokenizer(Tokenizer): + def __call__(self, value, positions=False, chars=False, keeporiginal=False, removestops=True, start_pos=0, start_char=0, mode='', **kwargs): + t = Token(positions, chars, removestops=removestops, mode=mode, **kwargs) + # cut_for_search returns generator, need to list if iterating repeatedly? + # Tokenizer expects yield Token. + try: + seglist = jieba.cut_for_search(value) + for w in seglist: + t.original = t.text = w + t.boost = 1.0 + if positions: + t.pos = start_pos + start_pos += 1 + if chars: + t.startchar = start_char + t.endchar = start_char + len(w) + start_char += len(w) + yield t + except Exception as e: + logger.error(f"Jieba tokenization error: {e}") + +def ChineseAnalyzer(): + return ChineseTokenizer() + +class SearchService: + def __init__(self): + # 索引存储路径 (使用配置文件中的存储根目录) + storage_root = Path(settings.STORAGE_ROOT) + # 如果是相对路径,转换为绝对路径 (相对于 backend 根目录,这里假设 settings 已经处理好或者我们手动处理) + # settings.STORAGE_ROOT 默认为 /data/... + # 本地开发时可能是 ./storage + if not storage_root.is_absolute(): + backend_dir = Path(__file__).parent.parent.parent + storage_root = (backend_dir / storage_root).resolve() + + self.index_dir = storage_root / "search_index" + + try: + self.index_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error(f"Failed to create search index directory: {e}") + + self.schema = Schema( + project_id=ID(stored=True), + path=ID(unique=True, stored=True), + title=TEXT(stored=True, analyzer=ChineseAnalyzer()), + content=TEXT(stored=True, analyzer=ChineseAnalyzer()) + ) + + self.ix = None + try: + self._load_or_create_index() + except Exception as e: + logger.error(f"Failed to initialize search index: {e}") + + def _load_or_create_index(self): + # 检查是否包含索引文件 + if index.exists_in(str(self.index_dir)): + try: + self.ix = index.open_dir(str(self.index_dir)) + except Exception as e: + logger.warning(f"Failed to open index, trying to recreate: {e}") + # 如果打开失败(例如损坏),尝试重建 + shutil.rmtree(str(self.index_dir)) + self.index_dir.mkdir(parents=True, exist_ok=True) + self.ix = index.create_in(str(self.index_dir), self.schema) + else: + self.ix = index.create_in(str(self.index_dir), self.schema) + + def _add_document_sync(self, project_id: str, path: str, title: str, content: str): + if not self.ix: + return + try: + writer = self.ix.writer() + writer.update_document( + project_id=str(project_id), + path=path, + title=title, + content=content + ) + writer.commit() + except Exception as e: + logger.error(f"Failed to add document to index: {e}") + # 如果是 LockError,可能需要清理锁? 暂时忽略 + + async def add_document(self, project_id: str, path: str, title: str, content: str): + """添加或更新文档索引 (Async)""" + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._add_document_sync, project_id, path, title, content) + + def _delete_document_sync(self, unique_path: str): + if not self.ix: + return + try: + writer = self.ix.writer() + writer.delete_by_term('path', unique_path) + writer.commit() + except Exception as e: + logger.error(f"Failed to delete document from index: {e}") + + async def delete_document(self, project_id: str, path: str): + """删除文档索引 (Async)""" + unique_path = f"{project_id}:{path}" + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._delete_document_sync, unique_path) + + def _delete_project_documents_sync(self, project_id: str): + if not self.ix: + return + try: + writer = self.ix.writer() + writer.delete_by_term('project_id', str(project_id)) + writer.commit() + except Exception as e: + logger.error(f"Failed to delete project documents: {e}") + + async def delete_project_documents(self, project_id: str): + """删除项目下的所有文档索引 (Async)""" + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._delete_project_documents_sync, project_id) + + def _search_sync(self, keyword: str, project_id: Optional[str] = None, limit: int = 20): + if not keyword or not self.ix: + return [] + + try: + with self.ix.searcher() as searcher: + parser = MultifieldParser(["title", "content"], schema=self.ix.schema) + query = parser.parse(keyword) + + filter_query = None + if project_id: + filter_parser = QueryParser("project_id", schema=self.ix.schema) + filter_query = filter_parser.parse(str(project_id)) + + results = searcher.search(query, filter=filter_query, limit=limit) + results.formatter = HtmlFormatter(tagname="mark", classname="search-highlight", termclass="search-term") + + search_results = [] + for hit in results: + # 提取原始路径 (去掉 project_id 前缀) + full_path = hit.get("path", "") + if ":" in full_path: + _, real_path = full_path.split(":", 1) + else: + real_path = full_path + + # 安全获取高亮 + try: + highlights = hit.highlights("content") or hit.highlights("title") or hit.get("title", "") + except: + highlights = hit.get("title", "") + + search_results.append({ + "project_id": hit.get("project_id"), + "path": real_path, + "title": hit.get("title"), + "highlights": highlights, + "score": hit.score + }) + + return search_results + except Exception as e: + logger.error(f"Search failed: {e}") + # 返回空列表而不是抛出异常 + return [] + + async def search(self, keyword: str, project_id: Optional[str] = None, limit: int = 20): + """搜索文档 (Async)""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._search_sync, keyword, project_id, limit) + + async def update_doc(self, project_id: int, path: str, title: str, content: str): + """添加或更新文档 (对外接口)""" + unique_path = f"{project_id}:{path}" + await self.add_document(str(project_id), unique_path, title, content) + + async def remove_doc(self, project_id: int, path: str): + """删除文档 (对外接口)""" + await self.delete_document(str(project_id), path) + + def rebuild_index_sync(self, documents: List[Dict]): + """同步重建索引""" + if not self.ix: + return + + try: + writer = self.ix.writer() + # 这里的 documents 必须包含 project_id, path, title, content + for doc in documents: + unique_path = f"{doc['project_id']}:{doc['path']}" + writer.update_document( + project_id=str(doc['project_id']), + path=unique_path, + title=doc['title'], + content=doc['content'] + ) + writer.commit() + except Exception as e: + logger.error(f"Rebuild index failed: {e}") + +search_service = SearchService() diff --git a/backend/requirements.txt b/backend/requirements.txt index cdc45e7..5c21ed7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,6 +18,7 @@ greenlet==3.2.4 h11==0.16.0 httptools==0.7.1 idna==3.11 +jieba==0.42.1 loguru==0.7.2 Mako==1.3.10 MarkupSafe==3.0.3 @@ -43,3 +44,4 @@ uvicorn==0.27.0 uvloop==0.22.1 watchfiles==1.1.1 websockets==15.0.1 +Whoosh==2.7.4 \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index c2aa77e..70ee76d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,9 +22,12 @@ "axios": "^1.6.2", "bytemd": "^1.22.0", "dayjs": "^1.11.10", + "github-slugger": "^2.0.0", + "mark.js": "^8.11.1", "pdfjs-dist": "5.4.296", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-highlight-words": "^0.21.0", "react-markdown": "^9.0.1", "react-pdf": "^10.2.0", "react-router-dom": "^6.20.1", diff --git a/frontend/src/api/search.js b/frontend/src/api/search.js index 894b99e..7aeb037 100644 --- a/frontend/src/api/search.js +++ b/frontend/src/api/search.js @@ -5,11 +5,23 @@ import request from '@/utils/request' /** * 搜索文档 + * @param {string} keyword - 搜索关键词 + * @param {string} project_id - 项目ID (可选,限制搜索范围) */ -export function searchDocuments(keyword) { +export function searchDocuments(keyword, project_id) { return request({ url: '/search/documents', method: 'get', - params: { keyword }, + params: { keyword, project_id }, + }) +} + +/** + * 重建搜索索引 (仅管理员) + */ +export function rebuildIndex() { + return request({ + url: '/search/rebuild-index', + method: 'post', }) } diff --git a/frontend/src/components/MainLayout/AppHeader.jsx b/frontend/src/components/MainLayout/AppHeader.jsx index 48bfefe..3c22038 100644 --- a/frontend/src/components/MainLayout/AppHeader.jsx +++ b/frontend/src/components/MainLayout/AppHeader.jsx @@ -208,6 +208,7 @@ function AppHeader({ collapsed, onToggle }) {
{/* Logo 区域 */}
+ logo

NexDocus

diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..b0435a7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,7 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +.search-highlight { + background-color: #ffd54f !important; + color: black !important; + font-weight: bold; + padding: 0 2px; + border-radius: 2px; +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 2e7b0c1..34eea89 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,11 +1,13 @@ import { useState, useEffect } from 'react' -import { Card, Row, Col, Statistic, Table, Spin } from 'antd' -import { UserOutlined, ProjectOutlined, FileTextOutlined } from '@ant-design/icons' +import { Card, Row, Col, Statistic, Table, Spin, Button, Tooltip, message } from 'antd' +import { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined } from '@ant-design/icons' import { getDashboardStats } from '@/api/dashboard' +import { rebuildIndex } from '@/api/search' import Toast from '@/components/Toast/Toast' function Dashboard() { const [loading, setLoading] = useState(true) + const [rebuilding, setRebuilding] = useState(false) const [stats, setStats] = useState({ user_count: 0, project_count: 0, @@ -34,6 +36,19 @@ function Dashboard() { } } + const handleRebuildIndex = async () => { + setRebuilding(true) + try { + await rebuildIndex() + message.success('索引重建任务已启动,请稍后搜索尝试') + } catch (error) { + console.error('Rebuild index error:', error) + message.error('重建索引失败: ' + (error.response?.data?.detail || error.message)) + } finally { + setRebuilding(false) + } + } + const userColumns = [ { title: '用户名', @@ -117,7 +132,23 @@ function Dashboard() { + 文档总数 + + + +
+ } value={stats.document_count} prefix={} valueStyle={{ color: '#cf1322' }} @@ -150,4 +181,4 @@ function Dashboard() { ) } -export default Dashboard +export default Dashboard \ No newline at end of file diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index 190d505..f353826 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -1,22 +1,38 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useNavigate, useSearchParams } from 'react-router-dom' -import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown } from 'antd' -import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined } from '@ant-design/icons' +import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown, Empty } from 'antd' +import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import rehypeSlug from 'rehype-slug' import rehypeHighlight from 'rehype-highlight' import 'highlight.js/styles/github.css' +import Highlighter from 'react-highlight-words' +import GithubSlugger from 'github-slugger' import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file' import { gitPull, gitPush, getGitRepos } from '@/api/project' import { getProjectShareInfo, updateShareSettings } from '@/api/share' +import { searchDocuments } from '@/api/search' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import Toast from '@/components/Toast/Toast' import './DocumentPage.css' const { Sider, Content } = Layout +// 高亮渲染组件 +const HighlightText = ({ text, keyword }) => { + if (!keyword || !text) return text; + return ( + + ) +} + function DocumentPage() { const { projectId } = useParams() const navigate = useNavigate() @@ -32,13 +48,19 @@ function DocumentPage() { const [shareInfo, setShareInfo] = useState(null) const [hasPassword, setHasPassword] = useState(false) const [password, setPassword] = useState('') - const [userRole, setUserRole] = useState('viewer') // 用户角色:owner/admin/editor/viewer + const [userRole, setUserRole] = useState('viewer') const [pdfViewerVisible, setPdfViewerVisible] = useState(false) const [pdfUrl, setPdfUrl] = useState('') const [pdfFilename, setPdfFilename] = useState('') - const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf' + const [viewMode, setViewMode] = useState('markdown') const [gitRepos, setGitRepos] = useState([]) const [projectName, setProjectName] = useState('') + + // 搜索相关状态 + const [searchKeyword, setSearchKeyword] = useState('') + const [matchedFilePaths, setMatchedFilePaths] = useState(new Set()) + const [isSearching, setIsSearching] = useState(false) + const contentRef = useRef(null) useEffect(() => { @@ -51,6 +73,64 @@ function DocumentPage() { } }, [projectId, userRole]) + // 处理搜索 + const handleSearch = async (value) => { + setSearchKeyword(value) + if (!value.trim()) { + setMatchedFilePaths(new Set()) + return + } + + setIsSearching(true) + try { + const res = await searchDocuments(value, projectId) + const paths = new Set(res.data.map(item => item.file_path)) + setMatchedFilePaths(paths) + + // 自动展开匹配的节点 + const keysToExpand = new Set(openKeys) + res.data.forEach(item => { + const parts = item.file_path.split('/') + let currentPath = '' + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] + keysToExpand.add(currentPath) + } + }) + setOpenKeys(Array.from(keysToExpand)) + } catch (error) { + console.error('Search error:', error) + } finally { + setIsSearching(false) + } + } + + // 过滤文件树 + const filteredTreeData = useMemo(() => { + if (!searchKeyword.trim()) return fileTree + + const loop = (data) => { + const result = [] + for (const node of data) { + const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase()) + const contentMatch = matchedFilePaths.has(node.key) + + if (node.children) { + const children = loop(node.children) + if (children.length > 0 || titleMatch) { + result.push({ ...node, children }) + } + } else { + if (titleMatch || contentMatch) { + result.push(node) + } + } + } + return result + } + return loop(fileTree) + }, [fileTree, searchKeyword, matchedFilePaths]) + const loadGitRepos = async () => { try { const res = await getGitRepos(projectId) @@ -75,6 +155,12 @@ function DocumentPage() { // 检查 URL 是否指定了文件 const fileParam = searchParams.get('file') + const keywordParam = searchParams.get('keyword') + + if (keywordParam) { + handleSearch(keywordParam) + } + if (fileParam) { setSelectedFile(fileParam) @@ -131,6 +217,9 @@ function DocumentPage() { // 转换文件树为菜单项 const convertTreeToMenuItems = (nodes) => { return nodes.map((node) => { + // 标题高亮处理 - 取消高亮,仅显示原始标题 + const titleNode = node.title.replace('.md', '') + if (!node.isLeaf) { // 目录 return { @@ -143,7 +232,7 @@ function DocumentPage() { // Markdown 文件 return { key: node.key, - label: node.title.replace('.md', ''), + label: titleNode, icon: , } } else if (node.title && node.title.endsWith('.pdf')) { @@ -181,6 +270,7 @@ function DocumentPage() { // 提取 markdown 标题生成目录 useEffect(() => { if (markdownContent) { + const slugger = new GithubSlugger() const headings = [] const lines = markdownContent.split('\n') @@ -189,15 +279,8 @@ function DocumentPage() { if (match) { const level = match[1].length const title = match[2] - // 模拟 rehype-slug/github-slugger 的 ID 生成规则 - const key = title - .toLowerCase() - .trim() - .replace(/\s+/g, '-') // 空格转连字符 - .replace(/[^\w\-\u4e00-\u9fa5]+/g, '') // 移除非单词字符(保留中文、数字、字母、下划线、连字符) - .replace(/\-\-+/g, '-') // 合并重复连字符 - .replace(/^-+/, '') // 去除头部连字符 - .replace(/-+$/, '') // 去除尾部连字符 + // 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致 + const key = slugger.slug(title) headings.push({ key: `#${key}`, @@ -586,7 +669,81 @@ function DocumentPage() { } } - const menuItems = convertTreeToMenuItems(fileTree) + const menuItems = convertTreeToMenuItems(filteredTreeData) + + // Markdown 内容高亮处理 + // 使用 components 替换文本节点,但这只对直接文本子节点有效 + // 对于深层嵌套,我们需要递归或使用 rehype 插件 + // 这里使用简单组件替换 + const markdownComponents = useMemo(() => { + if (!searchKeyword) { + return { + a: ({ node, href, children, ...props }) => { + const isExternal = href && (href.startsWith('http') || href.startsWith('//')); + return ( + handleMarkdownLink(e, href)} + target={isExternal ? '_blank' : undefined} + rel={isExternal ? 'noopener noreferrer' : undefined} + {...props} + > + {children} + + ); + }, + } + } + + // 搜索模式下,尝试高亮 + // 注意:这可能不完美,但比没有好 + const highlightRenderer = (Tag) => ({ node, children, ...props }) => { + // 如果 children 是字符串,高亮 + if (typeof children === 'string') { + return + } + // 如果是数组,遍历 + if (Array.isArray(children)) { + const newChildren = children.map((child, idx) => { + if (typeof child === 'string') { + return + } + return child + }) + return {newChildren} + } + return {children} + } + + return { + a: ({ node, href, children, ...props }) => { + const isExternal = href && (href.startsWith('http') || href.startsWith('//')); + return ( + handleMarkdownLink(e, href)} + target={isExternal ? '_blank' : undefined} + rel={isExternal ? 'noopener noreferrer' : undefined} + {...props} + > + {typeof children === 'string' ? : children} + + ); + }, + p: highlightRenderer('p'), + li: highlightRenderer('li'), + h1: highlightRenderer('h1'), + h2: highlightRenderer('h2'), + h3: highlightRenderer('h3'), + h4: highlightRenderer('h4'), + h5: highlightRenderer('h5'), + h6: highlightRenderer('h6'), + span: highlightRenderer('span'), + td: highlightRenderer('td'), + th: highlightRenderer('th'), + div: highlightRenderer('div'), + } + }, [searchKeyword]) return (
@@ -623,15 +780,34 @@ function DocumentPage() {
- + {/* 搜索框 */} +
+ setSearchKeyword(e.target.value)} + onSearch={handleSearch} + loading={isSearching} + enterButton + /> +
+ + {filteredTreeData.length > 0 ? ( + + ) : ( +
+ +
+ )} {/* 右侧内容区 */} @@ -654,22 +830,7 @@ function DocumentPage() { { - const isExternal = href && (href.startsWith('http') || href.startsWith('//')); - return ( - handleMarkdownLink(e, href)} - target={isExternal ? '_blank' : undefined} - rel={isExternal ? 'noopener noreferrer' : undefined} - {...props} - > - {children} - - ); - }, - }} + components={markdownComponents} > {markdownContent} @@ -716,7 +877,7 @@ function DocumentPage() { title: (
- {item.title} +
), }))} @@ -743,6 +904,7 @@ function DocumentPage() { {/* 分享模态框 */} + {/* ... keeping the modal ... */}

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

- 全流程文档共享,提升团队协作效率的新一代解决方案。 + 全流程文档协作共享,让每一份文档都产生价值。

diff --git a/frontend/src/pages/Preview/PreviewPage.jsx b/frontend/src/pages/Preview/PreviewPage.jsx index 4b51759..c20149a 100644 --- a/frontend/src/pages/Preview/PreviewPage.jsx +++ b/frontend/src/pages/Preview/PreviewPage.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo } from 'react' -import { useParams } from 'react-router-dom' -import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor } from 'antd' -import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined } from '@ant-design/icons' +import { useParams, useSearchParams } from 'react-router-dom' +import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor, Empty } from 'antd' +import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined } from '@ant-design/icons' import { Viewer } from '@bytemd/react' import gfm from '@bytemd/plugin-gfm' import highlight from '@bytemd/plugin-highlight' @@ -11,14 +11,32 @@ import gemoji from '@bytemd/plugin-gemoji' import 'bytemd/dist/index.css' import rehypeSlug from 'rehype-slug' import 'highlight.js/styles/github.css' +import Mark from 'mark.js' +import Highlighter from 'react-highlight-words' +import GithubSlugger from 'github-slugger' import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share' +import { searchDocuments } from '@/api/search' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import './PreviewPage.css' const { Sider, Content } = Layout +// 高亮组件 (用于 Tree) +const HighlightText = ({ text, keyword }) => { + if (!keyword || !text) return text; + return ( + + ) +} + function PreviewPage() { const { projectId } = useParams() + const [searchParams] = useSearchParams() const [projectInfo, setProjectInfo] = useState(null) const [fileTree, setFileTree] = useState([]) const [selectedFile, setSelectedFile] = useState('') @@ -29,15 +47,22 @@ function PreviewPage() { const [tocItems, setTocItems] = useState([]) const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [password, setPassword] = useState('') - const [accessPassword, setAccessPassword] = useState(null) // 已验证的密码 + const [accessPassword, setAccessPassword] = useState(null) const [siderCollapsed, setSiderCollapsed] = useState(false) const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false) const [isMobile, setIsMobile] = useState(false) const [pdfViewerVisible, setPdfViewerVisible] = useState(false) const [pdfUrl, setPdfUrl] = useState('') const [pdfFilename, setPdfFilename] = useState('') - const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf' + const [viewMode, setViewMode] = useState('markdown') + + // 搜索相关 + const [searchKeyword, setSearchKeyword] = useState('') + const [matchedFilePaths, setMatchedFilePaths] = useState(new Set()) + const [isSearching, setIsSearching] = useState(false) + const contentRef = useRef(null) + const viewerRef = useRef(null) // ByteMD 插件配置 const plugins = useMemo(() => [ @@ -51,6 +76,22 @@ function PreviewPage() { } ], []) + // mark.js 高亮 + useEffect(() => { + if (viewerRef.current && viewMode === 'markdown') { + const instance = new Mark(viewerRef.current) + instance.unmark() + + if (searchKeyword.trim()) { + instance.mark(searchKeyword, { + element: 'span', + className: 'search-highlight', + exclude: ['pre', 'code', '.toc-content'] + }) + } + } + }, [markdownContent, searchKeyword, viewMode]) + // 检测是否为移动设备 useEffect(() => { const checkMobile = () => { @@ -73,10 +114,8 @@ function PreviewPage() { setProjectInfo(info) if (info.has_password) { - // 需要密码验证 setPasswordModalVisible(true) } else { - // 无需密码,直接加载文档树 loadFileTree() } } catch (error) { @@ -110,9 +149,52 @@ function PreviewPage() { const tree = res.data || [] setFileTree(tree) - // 默认打开 README.md const readmeNode = findReadme(tree) - if (readmeNode) { + + // Check query params + const fileParam = searchParams.get('file') + const keywordParam = searchParams.get('keyword') + + if (keywordParam) { + handleSearch(keywordParam) + } + + if (fileParam) { + // Deep link to file + if (fileParam.toLowerCase().endsWith('.pdf')) { + let url = getPreviewDocumentUrl(projectId, fileParam) + // ... params logic repeated from handleMenuClick ... + // Simplify: just call logic or set state + // Since we need token/password logic, let's reuse handleMenuClick logic if possible or copy it. + // For simplicity, just set selection and let user click? No, auto load. + + // Copy logic for PDF url construction + const params = [] + if (pwd || accessPassword) params.push(`access_pass=${encodeURIComponent(pwd || accessPassword)}`) + const token = localStorage.getItem('access_token') + if (token) params.push(`token=${encodeURIComponent(token)}`) + if (params.length > 0) url += `?${params.join('&')}` + + setSelectedFile(fileParam) + setPdfUrl(url) + setPdfFilename(fileParam.split('/').pop()) + setViewMode('pdf') + } else { + setSelectedFile(fileParam) + loadMarkdown(fileParam, pwd || accessPassword) + } + + // Expand tree to file + const parts = fileParam.split('/') + const allParentPaths = [] + let currentPath = '' + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] + allParentPaths.push(currentPath) + } + setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])]) + + } else if (readmeNode) { setSelectedFile(readmeNode.key) loadMarkdown(readmeNode.key, pwd || accessPassword) } @@ -125,7 +207,64 @@ function PreviewPage() { } } - // 查找根目录的 README.md + // 搜索处理 + const handleSearch = async (value) => { + setSearchKeyword(value) + if (!value.trim()) { + setMatchedFilePaths(new Set()) + return + } + + setIsSearching(true) + try { + const res = await searchDocuments(value, projectId) + const paths = new Set(res.data.map(item => item.file_path)) + setMatchedFilePaths(paths) + + // 自动展开匹配的节点 (Assuming this comment might be there or not, better context: keysToExpand) + const keysToExpand = new Set(openKeys) + res.data.forEach(item => { + const parts = item.file_path.split('/') + let currentPath = '' + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] + keysToExpand.add(currentPath) + } + }) + setOpenKeys(Array.from(keysToExpand)) + } catch (error) { + console.error('Search error:', error) + } finally { + setIsSearching(false) + } + } + + // 过滤树 + const filteredTreeData = useMemo(() => { + if (!searchKeyword.trim()) return fileTree + + const loop = (data) => { + const result = [] + for (const node of data) { + const titleMatch = node.title.toLowerCase().includes(searchKeyword.toLowerCase()) + const contentMatch = matchedFilePaths.has(node.key) + + if (node.children) { + const children = loop(node.children) + if (children.length > 0 || titleMatch) { + result.push({ ...node, children }) + } + } else { + if (titleMatch || contentMatch) { + result.push(node) + } + } + } + return result + } + return loop(fileTree) + }, [fileTree, searchKeyword, matchedFilePaths]) + const findReadme = (nodes) => { for (const node of nodes) { if (node.title === 'README.md' && node.isLeaf) { @@ -135,9 +274,10 @@ function PreviewPage() { return null } - // 转换文件树为菜单项 const convertTreeToMenuItems = (nodes) => { return nodes.map((node) => { + const labelNode = node.title.replace('.md', '') + if (!node.isLeaf) { return { key: node.key, @@ -148,7 +288,7 @@ function PreviewPage() { } else if (node.title && node.title.endsWith('.md')) { return { key: node.key, - label: node.title.replace('.md', ''), + label: labelNode, icon: , } } else if (node.title && node.title.endsWith('.pdf')) { @@ -162,7 +302,6 @@ function PreviewPage() { }).filter(Boolean) } - // 加载 markdown 文件 const loadMarkdown = async (filePath, pwd = null) => { setLoading(true) setTocItems([]) @@ -170,12 +309,10 @@ function PreviewPage() { const res = await getPreviewFile(projectId, filePath, pwd || accessPassword) setMarkdownContent(res.data?.content || '') - // 移动端自动关闭侧边栏 if (isMobile) { setMobileDrawerVisible(false) } - // 滚动到顶部 if (contentRef.current) { contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) } @@ -192,9 +329,9 @@ function PreviewPage() { } } - // 提取 markdown 标题生成目录 useEffect(() => { if (markdownContent) { + const slugger = new GithubSlugger() const headings = [] const lines = markdownContent.split('\n') @@ -203,7 +340,8 @@ function PreviewPage() { if (match) { const level = match[1].length const title = match[2] - const key = title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + // 使用标准的 github-slugger 生成 ID,确保与 rehype-slug 一致 + const key = slugger.slug(title) headings.push({ key: `#${key}`, @@ -217,7 +355,7 @@ function PreviewPage() { setTocItems(headings) } }, [markdownContent]) - // 解析相对路径 + const resolveRelativePath = (currentPath, relativePath) => { if (relativePath.startsWith('/')) { return relativePath.substring(1) @@ -240,7 +378,6 @@ function PreviewPage() { return dirParts.join('/') } - // 处理 markdown 内部链接点击 const handleMarkdownLink = (e, href) => { if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#')) { return @@ -257,7 +394,6 @@ function PreviewPage() { try { decodedHref = decodeURIComponent(href) } catch (err) { - // ignore } const targetPath = resolveRelativePath(selectedFile, decodedHref) @@ -278,7 +414,6 @@ function PreviewPage() { handleMenuClick({ key: targetPath }) } - // 处理内容区域点击(委托处理链接点击) const handleContentClick = (e) => { const target = e.target.closest('a') if (target) { @@ -289,22 +424,17 @@ function PreviewPage() { } } - // 处理菜单点击 const handleMenuClick = ({ key }) => { setSelectedFile(key) - // 检查是否是PDF文件 if (key.toLowerCase().endsWith('.pdf')) { - // 显示PDF - 使用预览API,添加必要的参数 let url = getPreviewDocumentUrl(projectId, key) const params = [] - // 如果有密码,添加密码参数 if (accessPassword) { params.push(`access_pass=${encodeURIComponent(accessPassword)}`) } - // 如果用户已登录(私密项目需要),添加token参数 const token = localStorage.getItem('access_token') if (token) { params.push(`token=${encodeURIComponent(token)}`) @@ -318,39 +448,16 @@ function PreviewPage() { setPdfFilename(key.split('/').pop()) setViewMode('pdf') } else { - // 加载Markdown文件 setViewMode('markdown') loadMarkdown(key) } } - const menuItems = convertTreeToMenuItems(fileTree) - - // 侧边栏内容 - const SiderContent = () => ( - <> -
-

{projectInfo?.name || '项目预览'}

- {projectInfo?.description && ( -

{projectInfo.description}

- )} -
- - - ) + const menuItems = convertTreeToMenuItems(filteredTreeData) return (
- {/* 移动端使用 Drawer,桌面端使用 Sider */} {isMobile ? ( <>
+ } placement="left" onClose={() => setMobileDrawerVisible(false)} open={mobileDrawerVisible} width="80%" > - +
+ {projectInfo?.description && ( +

{projectInfo.description}

+ )} +
+ + {/* 搜索框 */} +
+ setSearchKeyword(e.target.value)} + onSearch={handleSearch} + loading={isSearching} + enterButton + /> +
+ + {filteredTreeData.length > 0 ? ( + + ) : ( +
+ +
+ )} ) : ( @@ -387,11 +524,47 @@ function PreviewPage() { collapsed={siderCollapsed} collapsedWidth={0} > - +
+
+ logo +

{projectInfo?.name || '项目预览'}

+
+ {projectInfo?.description && ( +

{projectInfo.description}

+ )} +
+ + {/* 搜索框 */} +
+ setSearchKeyword(e.target.value)} + onSearch={handleSearch} + loading={isSearching} + enterButton + /> +
+ + {filteredTreeData.length > 0 ? ( + + ) : ( +
+ +
+ )} )} - {/* 右侧内容区 */}
@@ -407,7 +580,7 @@ function PreviewPage() { filename={pdfFilename} /> ) : ( -
+
- {/* 返回顶部按钮 - 仅在markdown模式显示 */} {viewMode === 'markdown' && ( } @@ -431,7 +603,6 @@ function PreviewPage() { )} - {/* 右侧TOC面板(仅桌面端且markdown模式显示) */} {!isMobile && viewMode === 'markdown' && !tocCollapsed && (
@@ -455,7 +626,7 @@ function PreviewPage() { title: (
- {item.title} +
), }))} @@ -468,7 +639,6 @@ function PreviewPage() { )} - {/* TOC展开按钮(仅桌面端) */} {!isMobile && tocCollapsed && (