修正了图片拷贝

main
mula.liu 2026-02-02 18:55:43 +08:00
parent e623c93bf2
commit b9f1b49572
4 changed files with 75 additions and 263 deletions

View File

@ -1,254 +1,9 @@
# NexDocus 功能增强实现计划 ## Stage 1: Add Back Icon to Project Detail View
**Goal**: Provide a clear way for users to return to the project list or previous page from the Project Browse Mode (DocumentPage).
**Success Criteria**: A Close icon is visible in the Sider Header and navigates the user back using history or fallback.
**Status**: Complete
## Stage 1: 路由调整和项目成员管理 ## Stage 2: Refine Navigation Logic
**Goal**: 调整路由结构,实现项目卡片上的成员管理功能 **Goal**: Ensure the back button handles entries from notifications or direct links correctly.
**Success Criteria**: **Success Criteria**: Uses browser history if available, otherwise defaults to project list.
- /projects/my 路由正常工作 **Status**: Complete
- /projects/share 路由正常工作
- 项目卡片上可以打开成员管理弹窗
- 可以添加/删除项目成员
**Tests**:
- 访问 /projects/my 显示我创建的项目
- 访问 /projects/share 显示我参与的项目
- 点击项目卡片的成员图标打开成员管理弹窗
- 成功添加用户到项目
- 成功从项目删除用户
**Status**: ✅ Completed
---
## Stage 2: 个人桌面统计增强
**Goal**: 在个人桌面增加"参加项目数"统计
**Success Criteria**:
- 个人桌面显示参加项目数
- 统计数据准确(我创建的项目 + 我参与的项目)
**Tests**:
- 创建新项目后,个人项目数 +1
- 加入协作项目后,参加项目数 +1
- 统计卡片正确显示两个数值
**Status**: ✅ Completed
---
## Stage 3: 参与项目视图和权限控制
**Goal**: 实现参与项目单独视图,区分所有者和成员权限
**Success Criteria**:
- /projects/share 页面单独展示我参与的项目(非所有者)
- 项目卡片根据角色显示不同操作选项
- 成员不能删除项目(删除按钮不显示)
- 成员可以编辑项目内文档
**Tests**:
- 参与者打开 /projects/share 只看到协作项目
- 参与者看不到删除项目按钮
- 参与者可以正常编辑文档
**Status**: ✅ Completed
---
## Stage 4: 操作日志功能
**Goal**: 为文档编辑操作记录日志
**Success Criteria**:
- 创建者编辑文档时记录到 operation_logs 表
- 参与者编辑文档时记录到 operation_logs 表
- 日志包含用户ID、操作类型、资源信息
**Tests**:
- 编辑文档后operation_logs 表有新记录
- 日志包含正确的用户ID和文档路径
- 不同用户编辑生成不同日志
**Status**: ✅ Completed
---
## Stage 5: 项目编辑功能
**Goal**: 在我的项目卡片上增加编辑功能
**Success Criteria**:
- 项目卡片显示编辑按钮(仅所有者)
- 点击编辑弹出表单
- 可以修改项目名称、描述、公开性
**Tests**:
- 项目所有者看到编辑按钮
- 编辑项目信息成功保存
- 非所有者不显示编辑按钮
**Status**: ✅ Completed
---
## Stage 6: 全局搜索功能
**Goal**: 实现全局搜索文档内容
**Success Criteria**:
- 后端提供搜索API搜索文档元数据+内容)
- 前端增加全局搜索框
- 搜索结果显示匹配的文档列表
- 点击结果跳转到对应文档
**Tests**:
- 搜索文档标题能找到对应文档
- 搜索文档内容能找到包含关键词的文档
- 搜索结果高亮显示关键词
- 点击搜索结果正确跳转
**Status**: ✅ Completed
---
## Stage 7: PDF文档混合模式
**Goal**: 支持项目中上传PDF文件并在Markdown和浏览模式下查看PDF
### 7.1 后端PDF文件上传支持
**Goal**: 后端支持PDF文件上传并存储到_assets/files目录
**实现内容**:
1. 修改`/files/{project_id}/upload-file`接口支持PDF格式.pdf
2. 扩展文件类型验证,允许`application/pdf`
3. PDF文件存储到`_assets/files/`目录
4. 返回PDF文件的访问路径
**Success Criteria**:
- 可以通过API上传PDF文件
- PDF文件正确存储到_assets/files目录
- 返回可访问的文件路径
- 上传其他格式文件时报错
**Tests**:
- 使用Postman上传PDF文件验证返回路径
- 验证文件确实存储在正确目录
- 尝试上传非法格式,验证错误提示
**Status**: ✅ Completed
---
### 7.2 文件树显示PDF文件
**Goal**: 前端文件树能够识别和显示PDF文件
**实现内容**:
1. 修改`StorageService.generate_tree()`包含_assets/files目录中的PDF文件
2. 前端文件树组件识别.pdf文件
3. PDF文件显示专用图标FilePdfOutlined
4. 文件树节点携带文件类型信息
**Success Criteria**:
- 文件树中显示PDF文件节点
- PDF文件有独特的图标
- 点击PDF文件能触发相应事件
**Tests**:
- 在项目中上传PDF后刷新文件树查看是否显示
- 验证PDF文件图标是否正确
- 点击PDF文件查看是否有响应
**Status**: ✅ Completed
---
### 7.3 Markdown编辑器支持插入PDF链接
**Goal**: 在MD编辑器中可以方便地插入PDF文件链接
**实现内容**:
1. 编辑器工具栏添加"插入PDF"按钮
2. 点击按钮弹出上传对话框
3. 上传成功后自动在光标位置插入Markdown链接`[文件名.pdf](/_assets/files/xxx.pdf)`
4. 支持拖拽PDF文件到编辑器可选
**Success Criteria**:
- 工具栏有"插入PDF"按钮
- 可以上传PDF并自动插入链接
- 插入的链接格式正确
- 编辑器预览时显示PDF链接
**Tests**:
- 点击"插入PDF"按钮,上传文件
- 验证插入的Markdown链接格式
- 保存后重新打开,验证链接是否保留
**Status**: ✅ Completed
---
### 7.4 PDF文件预览功能
**Goal**: 浏览模式下点击PDF链接或文件树中的PDF文件时可以在线预览PDF
**实现内容**:
1. 安装PDF预览库react-pdf
2. 创建PDFViewer组件支持
- 分页显示
- 缩放控制(放大/缩小/适应宽度)
- 页码跳转
- 下载功能
3. 在浏览模式下点击PDF链接时打开预览器
4. 从文件树点击PDF文件时也打开预览器
5. 后端提供PDF文件访问接口权限校验
**Success Criteria**:
- 可以在浏览器中预览PDF文件
- 支持翻页、缩放等基本操作
- 只有有权限的用户可以查看PDF
- PDF预览界面美观易用
**Tests**:
- 在浏览模式下点击PDF链接验证能否打开
- 测试翻页、缩放、下载功能
- 测试权限无权限用户访问PDF时返回403
- 测试不同大小的PDF文件加载速度
**Status**: ✅ Completed
---
### 7.5 搜索集成PDF文件名
**Goal**: 全局搜索时可以搜索到PDF文件名
**实现内容**:
1. 修改`/search/documents`接口添加对PDF文件的搜索
2. 搜索结果中显示PDF文件带"PDF文档"标签
3. 点击搜索结果中的PDF文件时打开预览
**Success Criteria**:
- 搜索PDF文件名能返回结果
- 搜索结果中PDF文件有明确标识
- 点击搜索结果可以打开PDF预览
**Tests**:
- 上传名为"产品说明书.pdf"的文件
- 搜索"说明书",验证是否出现在结果中
- 点击搜索结果验证是否打开PDF预览
**Status**: ✅ Completed
---
## Stage 8: 全文内容检索增强 (Whoosh)
**Goal**: 引入 Whoosh 搜索引擎,实现基于文档内容的全文检索和高亮显示。
### 核心方案
- **技术**: Whoosh (纯Python搜索引擎) + Jieba (中文分词)
- **存储**: 本地文件系统 `storage/search_index`
- **同步**: 实时文件操作钩子 + 批量重建脚本
### 8.1 基础设施搭建
**Goal**: 引入依赖并创建搜索服务
**Status**: Not Started
**Implementation**:
1. 添加 `Whoosh`, `jieba` 到 requirements.txt
2. 创建 `backend/app/services/search_service.py`
- 定义 Index Schema (project_id, path, title, content)
- 实现 Add/Remove/Update Document 方法
- 实现 Search 方法(支持高亮)
### 8.2 索引同步机制
**Goal**: 确保文件修改实时反映到搜索结果
**Status**: Not Started
**Implementation**:
1. 修改 `backend/app/api/v1/files.py`
- 在 `save_file` 后调用 `index.add_document`
- 在 `operate_file` (rename/move) 后更新索引
- 在 `operate_file` (delete) 后删除索引
- 在 `import_documents` 后批量添加索引
2. 创建初始化脚本/API`POST /api/v1/system/rebuild-index`
### 8.3 搜索接口升级
**Goal**: 升级搜索API支持内容搜索
**Status**: Not Started
**Implementation**:
1. 修改 `backend/app/api/v1/search.py`
2. 调用 `SearchService.search`
3. 返回包含 `highlight` 摘要的结果结构
4. 前端展示优化(显示匹配片段)

View File

@ -679,7 +679,8 @@ function DocumentEditor() {
if (file) { if (file) {
const url = await handleImageUpload(file) const url = await handleImageUpload(file)
if (url) { if (url) {
ctx.appendBlock(`![image](${url})`) ctx.editor.replaceSelection(`![image](${url})`)
ctx.editor.focus()
Toast.success('成功', '图片上传成功') Toast.success('成功', '图片上传成功')
} }
} }
@ -691,6 +692,13 @@ function DocumentEditor() {
], ],
} }
//
const editorRefPlugin = {
editorEffect: (ctx) => {
editorCtxRef.current = ctx
},
}
return [ return [
gfm(), gfm(),
highlight(), highlight(),
@ -699,6 +707,7 @@ function DocumentEditor() {
gemoji(), gemoji(),
uploadImagesPlugin, uploadImagesPlugin,
internalLinkPlugin, internalLinkPlugin,
editorRefPlugin,
] ]
}, [projectId]) }, [projectId])
@ -718,7 +727,13 @@ function DocumentEditor() {
if (url) { if (url) {
// markdown // markdown
const imageMarkdown = `![image](${url})` const imageMarkdown = `![image](${url})`
setFileContent(prev => prev + '\n' + imageMarkdown) if (editorCtxRef.current && editorCtxRef.current.editor) {
const editor = editorCtxRef.current.editor
editor.replaceSelection(imageMarkdown)
editor.focus()
} else {
setFileContent(prev => prev + '\n' + imageMarkdown)
}
Toast.success('成功', '图片上传成功') Toast.success('成功', '图片上传成功')
} }
} }
@ -740,7 +755,13 @@ function DocumentEditor() {
if (url) { if (url) {
const imageMarkdown = `![${file.name}](${url})` const imageMarkdown = `![${file.name}](${url})`
setFileContent(prev => prev + '\n' + imageMarkdown) if (editorCtxRef.current && editorCtxRef.current.editor) {
const editor = editorCtxRef.current.editor
editor.replaceSelection(imageMarkdown)
editor.focus()
} else {
setFileContent(prev => prev + '\n' + imageMarkdown)
}
Toast.success('成功', '图片上传成功') Toast.success('成功', '图片上传成功')
} }
} }

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown, Empty } from 'antd' 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 { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
@ -67,6 +67,14 @@ function DocumentPage() {
loadFileTree() loadFileTree()
}, [projectId]) }, [projectId])
const handleClose = () => {
if (userRole === 'owner') {
navigate('/projects/my')
} else {
navigate('/projects/share')
}
}
// URL // URL
useEffect(() => { useEffect(() => {
// //
@ -756,7 +764,19 @@ function DocumentPage() {
{/* 左侧目录 */} {/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light"> <Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header"> <div className="docs-sider-header">
<h2>{projectName}</h2> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
{projectName}
</h2>
<Tooltip title="关闭">
<Button
type="text"
icon={<CloseOutlined />}
onClick={handleClose}
style={{ marginLeft: 8 }}
/>
</Tooltip>
</div>
<div className="docs-sider-actions"> <div className="docs-sider-actions">
<Space size={8}> <Space size={8}>
{/* 只有 owner/admin/editor 可以编辑和Git操作 */} {/* 只有 owner/admin/editor 可以编辑和Git操作 */}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useSearchParams } from 'react-router-dom' import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor, Empty } from 'antd' import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor, Empty, Tooltip } from 'antd'
import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined } from '@ant-design/icons' import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
import { Viewer } from '@bytemd/react' import { Viewer } from '@bytemd/react'
import gfm from '@bytemd/plugin-gfm' import gfm from '@bytemd/plugin-gfm'
import highlight from '@bytemd/plugin-highlight' import highlight from '@bytemd/plugin-highlight'
@ -36,7 +36,17 @@ const HighlightText = ({ text, keyword }) => {
function PreviewPage() { function PreviewPage() {
const { projectId } = useParams() const { projectId } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const handleClose = () => {
// 退
if (window.history.length > 1) {
navigate(-1)
} else {
navigate('/projects')
}
}
const [projectInfo, setProjectInfo] = useState(null) const [projectInfo, setProjectInfo] = useState(null)
const [fileTree, setFileTree] = useState([]) const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('') const [selectedFile, setSelectedFile] = useState('')
@ -473,7 +483,10 @@ function PreviewPage() {
</Button> </Button>
<Drawer <Drawer
title={ title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
<img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} /> <img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} />
<span>{projectInfo?.name || '项目预览'}</span> <span>{projectInfo?.name || '项目预览'}</span>
</div> </div>
@ -528,7 +541,10 @@ function PreviewPage() {
collapsedWidth={0} collapsedWidth={0}
> >
<div className="preview-sider-header"> <div className="preview-sider-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> <div
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
<img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} /> <img src="/favicon.svg" alt="logo" style={{ width: 24, height: 24 }} />
<h2 style={{ margin: 0 }}>{projectInfo?.name || '项目预览'}</h2> <h2 style={{ margin: 0 }}>{projectInfo?.name || '项目预览'}</h2>
</div> </div>