修改兼容性

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文档混合模式
**Goal**: 支持项目中上传PDF文件并在Markdown和浏览模式下查看PDF
@ -271,99 +215,40 @@
---
## PDF功能技术选型
## Stage 8: 全文内容检索增强 (Whoosh)
**Goal**: 引入 Whoosh 搜索引擎,实现基于文档内容的全文检索和高亮显示。
### PDF预览库对比
### 核心方案
- **技术**: Whoosh (纯Python搜索引擎) + Jieba (中文分词)
- **存储**: 本地文件系统 `storage/search_index`
- **同步**: 实时文件操作钩子 + 批量重建脚本
**方案1: react-pdf** (推荐)
- 优点:
- 轻量级基于pdf.js封装
- API简单易用
- 支持分页、缩放、搜索
- TypeScript支持良好
- 缺点:
- 需要配置worker
- 大文件可能需要优化
- 安装:`npm install react-pdf pdfjs-dist`
### 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 方法(支持高亮)
**方案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直接嵌入**
- 优点:
- 最简单,无需额外依赖
- 浏览器原生支持
- 缺点:
- 功能有限,样式难以自定义
- 移动端体验差
### 推荐方案
**使用 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文件是否需要版本控制建议暂不支持
### 8.3 搜索接口升级
**Goal**: 升级搜索API支持内容搜索
**Status**: Not Started
**Implementation**:
1. 修改 `backend/app/api/v1/search.py`
2. 调用 `SearchService.search`
3. 返回包含 `highlight` 摘要的结果结构
4. 前端展示优化(显示匹配片段)

View File

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

View File

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

View File

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

View File

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