修改兼容性

main
mula.liu 2026-01-22 19:52:29 +08:00
parent 2255982c2c
commit 08f4115889
7 changed files with 170 additions and 241 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

View File

@ -89,62 +89,6 @@
--- ---
## 技术决策
### 数据库变更
- 无需修改现有表结构project_members 和 operation_logs 已存在)
### 后端API新增
1. `GET /projects/my` - 获取我创建的项目
2. `GET /projects/shared` - 获取我参与的项目
3. `POST /projects/{project_id}/members` - 添加项目成员(已存在,需测试)
4. `DELETE /projects/{project_id}/members/{user_id}` - 删除项目成员(需新增)
5. `POST /search/documents` - 全局文档搜索(需新增)
6. 修改 `POST /files/{project_id}/file` - 增加操作日志记录
### 前端路由调整
- `/projects/my` - 我的项目(原 /projects
- `/projects/share` - 参与的项目(新增)
- 保持 `/projects` 作为默认,重定向到 `/projects/my`
### 权限控制策略
- 项目所有者:完全控制(增删改查、成员管理、删除项目)
- 项目管理员ADMIN文档管理、成员管理
- 项目编辑者EDITOR文档编辑
- 项目查看者VIEWER仅查看
### 操作日志设计
```json
{
"user_id": 1,
"username": "admin",
"operation_type": "update",
"resource_type": "document",
"resource_id": 123,
"detail": {
"project_id": 10,
"file_path": "/docs/readme.md",
"action": "edit",
"changes": "content_modified"
},
"ip_address": "192.168.1.1",
"status": 1
}
```
### 全局搜索实现方案
1. **基础版本(当前阶段)**:
- 搜索 document_meta 表的 title 字段
- 读取磁盘文件内容进行关键词匹配
- 适用于小规模项目
2. **未来优化(可选)**:
- 使用 Elasticsearch 全文检索
- 添加文档内容索引到数据库
- 实现增量索引更新
---
## Stage 7: PDF文档混合模式 ## Stage 7: PDF文档混合模式
**Goal**: 支持项目中上传PDF文件并在Markdown和浏览模式下查看PDF **Goal**: 支持项目中上传PDF文件并在Markdown和浏览模式下查看PDF
@ -271,99 +215,40 @@
--- ---
## PDF功能技术选型 ## Stage 8: 全文内容检索增强 (Whoosh)
**Goal**: 引入 Whoosh 搜索引擎,实现基于文档内容的全文检索和高亮显示。
### PDF预览库对比 ### 核心方案
- **技术**: Whoosh (纯Python搜索引擎) + Jieba (中文分词)
- **存储**: 本地文件系统 `storage/search_index`
- **同步**: 实时文件操作钩子 + 批量重建脚本
**方案1: react-pdf** (推荐) ### 8.1 基础设施搭建
- 优点: **Goal**: 引入依赖并创建搜索服务
- 轻量级基于pdf.js封装 **Status**: Not Started
- API简单易用 **Implementation**:
- 支持分页、缩放、搜索 1. 添加 `Whoosh`, `jieba` 到 requirements.txt
- TypeScript支持良好 2. 创建 `backend/app/services/search_service.py`
- 缺点: - 定义 Index Schema (project_id, path, title, content)
- 需要配置worker - 实现 Add/Remove/Update Document 方法
- 大文件可能需要优化 - 实现 Search 方法(支持高亮)
- 安装:`npm install react-pdf pdfjs-dist`
**方案2: @react-pdf-viewer** ### 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`
**方案3: iframe直接嵌入** ### 8.3 搜索接口升级
- 优点: **Goal**: 升级搜索API支持内容搜索
- 最简单,无需额外依赖 **Status**: Not Started
- 浏览器原生支持 **Implementation**:
- 缺点: 1. 修改 `backend/app/api/v1/search.py`
- 功能有限,样式难以自定义 2. 调用 `SearchService.search`
- 移动端体验差 3. 返回包含 `highlight` 摘要的结果结构
4. 前端展示优化(显示匹配片段)
### 推荐方案
**使用 react-pdf**,理由:
1. 轻量级,适合当前项目规模
2. 功能足够,易于扩展
3. 社区活跃,文档完善
### 文件存储结构
```
projects/
{storage_key}/
_assets/
images/ # 图片文件(现有)
files/ # PDF等文档文件新增
README.md
其他目录和文件
```
### API设计
**1. 上传PDF文件扩展现有接口**
```
POST /api/v1/files/{project_id}/upload-file
```
请求参数:
- `file`: 文件二进制
- `subfolder`: "files"(新增,默认"images"
返回:
```json
{
"code": 200,
"data": {
"filename": "abc123.pdf",
"original_filename": "产品说明书.pdf",
"path": "_assets/files/abc123.pdf",
"size": 1024000
}
}
```
**2. 访问PDF文件新接口**
```
GET /api/v1/files/{project_id}/asset?path={relative_path}
```
- 权限校验:检查用户是否有项目访问权限
- 返回PDF文件流Content-Type: application/pdf
### Markdown链接格式
```markdown
# 项目文档
查看详细说明:[产品说明书](/_assets/files/abc123.pdf)
```
### 待确认问题
1. **文件大小限制**PDF文件最大允许多少MB建议20MB
2. **是否支持PDF注释/标注**:暂不支持,后续可扩展
3. **PDF缩略图**是否需要在文件树显示PDF缩略图建议暂不支持
4. **PDF搜索文本内容**是否需要搜索PDF内部文字建议暂不支持
5. **并发上传限制**是否限制同时上传的PDF数量
6. **历史版本**PDF文件是否需要版本控制建议暂不支持

View File

@ -39,17 +39,16 @@
.sider-actions { .sider-actions {
display: flex; display: flex;
gap: 8px;
align-items: center; align-items: center;
} }
.mode-toggle-btn { .mode-toggle-btn {
border-radius: 6px !important;
font-weight: 500 !important; font-weight: 500 !important;
height: 32px !important; height: 32px !important;
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
padding: 0 12px !important; justify-content: center !important;
padding: 4px 15px !important;
border: none !important; border: none !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
} }
@ -70,9 +69,6 @@
} }
.sider-actions .ant-btn:not(.mode-toggle-btn) { .sider-actions .ant-btn:not(.mode-toggle-btn) {
background: #f0f0f0;
border: 1px solid #d9d9d9;
border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -70,7 +70,7 @@ function DocumentEditor() {
const [isPdfSelected, setIsPdfSelected] = useState(false) // PDF const [isPdfSelected, setIsPdfSelected] = useState(false) // PDF
const [linkModalVisible, setLinkModalVisible] = useState(false) const [linkModalVisible, setLinkModalVisible] = useState(false)
const [linkTarget, setLinkTarget] = useState(null) const [linkTarget, setLinkTarget] = useState(null)
const [projectName, setProjectName] = useState('项目文档') // const [projectName, setProjectName] = useState('') //
const editorCtxRef = useRef(null) const editorCtxRef = useRef(null)
// //
@ -124,7 +124,7 @@ function DocumentEditor() {
const res = await getProjectTree(projectId) const res = await getProjectTree(projectId)
const data = res.data || {} const data = res.data || {}
const tree = data.tree || data || [] // const tree = data.tree || data || [] //
const name = data.project_name || '项目文档' const name = data.project_name
setTreeData(tree) setTreeData(tree)
setProjectName(name) setProjectName(name)
} catch (error) { } catch (error) {
@ -864,55 +864,55 @@ function DocumentEditor() {
<div className="sider-header"> <div className="sider-header">
<h2>{projectName}</h2> <h2>{projectName}</h2>
<div className="sider-actions"> <div className="sider-actions">
<Button <Space size={8}>
type="primary"
size="small"
className="mode-toggle-btn exit-edit"
icon={<EyeOutlined />}
onClick={() => navigate(`/projects/${projectId}/docs`)}
>
退出编辑
</Button>
<Tooltip title="添加文件">
<Button <Button
type="text" type="primary"
size="middle" size="middle"
icon={<FileAddOutlined />} className="mode-toggle-btn exit-edit"
onClick={handleCreateFile} icon={<EyeOutlined />}
/> onClick={() => navigate(`/projects/${projectId}/docs`)}
</Tooltip> >
<Tooltip title="添加目录"> 退出
<Button </Button>
type="text" <Space.Compact>
size="middle" <Tooltip title="添加文件">
icon={<FolderAddOutlined />} <Button
onClick={handleCreateDir} size="middle"
/> icon={<FileAddOutlined />}
</Tooltip> onClick={handleCreateFile}
<Upload />
multiple </Tooltip>
accept=".md,.pdf,application/pdf" <Tooltip title="添加目录">
showUploadList={false} <Button
fileList={fileList} size="middle"
beforeUpload={() => false} icon={<FolderAddOutlined />}
onChange={handleImportDocuments} onClick={handleCreateDir}
> />
<Tooltip title="上传文档"> </Tooltip>
<Button <Upload
type="text" multiple
size="middle" accept=".md,.pdf,application/pdf"
icon={<UploadOutlined />} showUploadList={false}
/> fileList={fileList}
</Tooltip> beforeUpload={() => false}
</Upload> onChange={handleImportDocuments}
<Tooltip title="导出文档"> >
<Button <Tooltip title="上传文档">
type="text" <Button
size="middle" size="middle"
icon={<DownloadOutlined />} icon={<UploadOutlined />}
onClick={handleExportDirectory} />
/> </Tooltip>
</Tooltip> </Upload>
<Tooltip title="导出文档">
<Button
size="middle"
icon={<DownloadOutlined />}
onClick={handleExportDirectory}
/>
</Tooltip>
</Space.Compact>
</Space>
</div> </div>
</div> </div>
{/* 上传进度条 */} {/* 上传进度条 */}

View File

@ -35,12 +35,10 @@
.docs-sider-actions { .docs-sider-actions {
display: flex; display: flex;
gap: 8px;
align-items: center; align-items: center;
} }
.mode-toggle-btn { .mode-toggle-btn {
border-radius: 6px !important;
font-weight: 500 !important; font-weight: 500 !important;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2); box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2);
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%) !important; background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%) !important;
@ -48,7 +46,8 @@
height: 32px !important; height: 32px !important;
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
padding: 0 12px !important; justify-content: center !important;
padding: 4px 15px !important;
} }
.mode-toggle-btn:hover { .mode-toggle-btn:hover {
@ -58,9 +57,6 @@
} }
.docs-sider-actions .ant-btn:not(.mode-toggle-btn) { .docs-sider-actions .ant-btn:not(.mode-toggle-btn) {
background: #f0f0f0;
border: 1px solid #d9d9d9;
border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } 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 } from 'antd' 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 { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
@ -20,6 +20,7 @@ const { Sider, Content } = Layout
function DocumentPage() { function DocumentPage() {
const { projectId } = useParams() const { projectId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [fileTree, setFileTree] = useState([]) const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('') const [selectedFile, setSelectedFile] = useState('')
const [markdownContent, setMarkdownContent] = useState('') const [markdownContent, setMarkdownContent] = useState('')
@ -37,7 +38,7 @@ function DocumentPage() {
const [pdfFilename, setPdfFilename] = useState('') const [pdfFilename, setPdfFilename] = useState('')
const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf' const [viewMode, setViewMode] = useState('markdown') // 'markdown' or 'pdf'
const [gitRepos, setGitRepos] = useState([]) const [gitRepos, setGitRepos] = useState([])
const [projectName, setProjectName] = useState('项目文档') const [projectName, setProjectName] = useState('')
const contentRef = useRef(null) const contentRef = useRef(null)
useEffect(() => { useEffect(() => {
@ -66,17 +67,50 @@ function DocumentPage() {
const data = res.data || {} const data = res.data || {}
const tree = data.tree || data || [] // const tree = data.tree || data || [] //
const role = data.user_role || 'viewer' const role = data.user_role || 'viewer'
const name = data.project_name || '项目文档' const name = data.project_name
setFileTree(tree) setFileTree(tree)
setUserRole(role) setUserRole(role)
setProjectName(name) setProjectName(name)
// README.md // URL
const readmeNode = findReadme(tree) const fileParam = searchParams.get('file')
if (readmeNode) { if (fileParam) {
setSelectedFile(readmeNode.key) setSelectedFile(fileParam)
loadMarkdown(readmeNode.key)
//
const parts = fileParam.split('/')
const allParentPaths = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]
currentPath = currentPath ? `${currentPath}/${part}` : part
allParentPaths.push(currentPath)
}
if (allParentPaths.length > 0) {
setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])])
}
// PDF Markdown
if (fileParam.toLowerCase().endsWith('.pdf')) {
let url = getDocumentUrl(projectId, fileParam)
const token = localStorage.getItem('access_token')
if (token) {
url += `?token=${encodeURIComponent(token)}`
}
setPdfUrl(url)
setPdfFilename(fileParam.split('/').pop())
setViewMode('pdf')
} else {
loadMarkdown(fileParam)
}
} else {
// README.md
const readmeNode = findReadme(tree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
loadMarkdown(readmeNode.key)
}
} }
} catch (error) { } catch (error) {
console.error('Load file tree error:', error) console.error('Load file tree error:', error)
@ -385,10 +419,9 @@ function DocumentPage() {
if (gitRepos.length <= 1) { if (gitRepos.length <= 1) {
// 0 1 // 0 1
return ( return (
<Space.Compact> <>
<Tooltip title="Git Pull"> <Tooltip title="Git Pull">
<Button <Button
type="text"
size="middle" size="middle"
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined />}
onClick={() => handleGitPull()} onClick={() => handleGitPull()}
@ -396,13 +429,12 @@ function DocumentPage() {
</Tooltip> </Tooltip>
<Tooltip title="Git Push"> <Tooltip title="Git Push">
<Button <Button
type="text"
size="middle" size="middle"
icon={<CloudUploadOutlined />} icon={<CloudUploadOutlined />}
onClick={() => handleGitPush()} onClick={() => handleGitPush()}
/> />
</Tooltip> </Tooltip>
</Space.Compact> </>
) )
} }
@ -419,12 +451,32 @@ function DocumentPage() {
onClick: () => handleGitPush(repo.id), onClick: () => handleGitPush(repo.id),
})) }))
if (gitRepos.length <= 1) {
return (
<>
<Tooltip title="Git Pull">
<Button
size="middle"
icon={<CloudDownloadOutlined />}
onClick={() => handleGitPull()}
/>
</Tooltip>
<Tooltip title="Git Push">
<Button
size="middle"
icon={<CloudUploadOutlined />}
onClick={() => handleGitPush()}
/>
</Tooltip>
</>
)
}
return ( return (
<Space.Compact> <>
<Dropdown menu={{ items: pullItems }}> <Dropdown menu={{ items: pullItems }}>
<Tooltip title="Git Pull"> <Tooltip title="Git Pull">
<Button <Button
type="text"
size="middle" size="middle"
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined />}
/> />
@ -433,13 +485,12 @@ function DocumentPage() {
<Dropdown menu={{ items: pushItems }}> <Dropdown menu={{ items: pushItems }}>
<Tooltip title="Git Push"> <Tooltip title="Git Push">
<Button <Button
type="text"
size="middle" size="middle"
icon={<CloudUploadOutlined />} icon={<CloudUploadOutlined />}
/> />
</Tooltip> </Tooltip>
</Dropdown> </Dropdown>
</Space.Compact> </>
) )
} }
@ -545,29 +596,30 @@ function DocumentPage() {
<div className="docs-sider-header"> <div className="docs-sider-header">
<h2>{projectName}</h2> <h2>{projectName}</h2>
<div className="docs-sider-actions"> <div className="docs-sider-actions">
{/* 只有 owner/admin/editor 可以编辑和Git操作 */} <Space size={8}>
{userRole !== 'viewer' && ( {/* 只有 owner/admin/editor 可以编辑和Git操作 */}
<> {userRole !== 'viewer' && (
<Button <Button
type="primary" type="primary"
size="small" size="middle"
className="mode-toggle-btn" className="mode-toggle-btn"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={handleEdit} onClick={handleEdit}
> >
编辑模式 编辑
</Button> </Button>
{renderGitActions()} )}
</> <Space.Compact>
)} {userRole !== 'viewer' && renderGitActions()}
<Tooltip title="分享"> <Tooltip title="分享">
<Button <Button
type="text" size="middle"
size="middle" icon={<ShareAltOutlined />}
icon={<ShareAltOutlined />} onClick={handleShare}
onClick={handleShare} />
/> </Tooltip>
</Tooltip> </Space.Compact>
</Space>
</div> </div>
</div> </div>