优化大pdf加载

main
mula.liu 2026-01-01 22:41:10 +08:00
parent 1319310b5a
commit bb323f89e4
16 changed files with 1187 additions and 646 deletions

View File

@ -341,16 +341,20 @@ async def upload_document(
async def get_document_file(
project_id: int,
path: str,
request: Request,
current_user: User = Depends(get_user_from_token_or_query),
db: AsyncSession = Depends(get_db)
):
"""
获取文档文件PDF等- 返回文件流
获取文档文件PDF等- 支持 HTTP Range 请求
Args:
project_id: 项目ID
path: 文件相对路径 "manual.pdf" "docs/guide.pdf"
"""
import re
import aiofiles
project = await check_project_access(project_id, current_user, db)
# 获取文件路径
@ -363,15 +367,57 @@ async def get_document_file(
content_type, _ = mimetypes.guess_type(str(file_path))
if not content_type:
content_type = "application/octet-stream"
# 返回文件流
# 获取文件大小
file_size = file_path.stat().st_size
# 检查是否有 Range 请求头
range_header = request.headers.get("range")
if range_header:
# 解析 Range 头: bytes=start-end
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
if range_match:
start = int(range_match.group(1))
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
end = min(end, file_size - 1)
# 读取指定范围的文件内容
async def file_iterator():
async with aiofiles.open(file_path, 'rb') as f:
await f.seek(start)
chunk_size = 1024 * 1024 # 1MB chunks
remaining = end - start + 1
while remaining > 0:
chunk = await f.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(end - start + 1),
}
return StreamingResponse(
file_iterator(),
status_code=206, # Partial Content
headers=headers,
media_type=content_type
)
# 正常请求,返回完整文件(添加 Accept-Ranges 头)
return FileResponse(
path=str(file_path),
media_type=content_type,
filename=file_path.name
filename=file_path.name,
headers={"Accept-Ranges": "bytes"}
)
@router.get("/{project_id}/assets/{subfolder}/{filename}")
async def get_asset_file(
project_id: int,

View File

@ -309,10 +309,8 @@ class StorageService:
detail=f"不支持的文件类型: {file_ext}。允许的类型: {', '.join(allowed_extensions)}"
)
# 生成唯一文件名(保留原始文件名+时间戳)
original_name = Path(file.filename).stem
timestamp = uuid.uuid4().hex[:8]
unique_filename = f"{original_name}_{timestamp}{file_ext}"
# 使用原始文件名(不添加时间戳)
unique_filename = file.filename
# 目标路径
if target_dir:

View File

@ -27,6 +27,8 @@
"react-markdown": "^9.0.1",
"react-pdf": "^10.2.0",
"react-router-dom": "^6.20.1",
"react-virtuoso": "^4.18.1",
"react-window": "^2.2.3",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",

View File

@ -129,4 +129,3 @@ export function getDocumentUrl(projectId, path) {
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
return `/api/v1/files/${projectId}/document/${encodedPath}`
}

View File

@ -78,4 +78,3 @@ export function getPreviewDocumentUrl(projectId, path) {
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
return `/api/v1/preview/${projectId}/document/${encodedPath}`
}

View File

@ -50,8 +50,8 @@
}
.trigger:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
}
/* 右侧区域 */
@ -83,8 +83,8 @@
}
.header-icon:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
}
.header-link {
@ -100,8 +100,8 @@
}
.header-link:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
}
.user-info {
@ -115,7 +115,7 @@
}
.user-info:hover {
background: rgba(184, 23, 141, 0.06);
background: rgba(22, 119, 255, 0.08);
}
.username {
@ -126,4 +126,4 @@
.ml-1 {
margin-left: 4px;
}
}

View File

@ -71,7 +71,7 @@ function AppHeader({ collapsed, onToggle }) {
<div className="header-left">
{/* Logo 区域 */}
<div className="header-logo">
<h2 style={{ margin: 0, color: '#1890ff', fontWeight: 'bold' }}>NEX Docus</h2>
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NEX Docus</h2>
</div>
{/* 折叠按钮 */}
@ -98,11 +98,16 @@ function AppHeader({ collapsed, onToggle }) {
))}
{/* 消息中心 */}
<Badge count={5} size="small" offset={[-3, 3]}>
<div className="header-icon" title="消息中心">
<div
className="header-link"
title="消息中心"
onClick={() => handleHeaderMenuClick('messages')}
>
<Badge count={5} size="small" offset={[4, -2]}>
<BellOutlined />
</div>
</Badge>
</Badge>
<span className="ml-1">消息</span>
</div>
{/* 用户下拉菜单 */}
<Dropdown

View File

@ -0,0 +1,130 @@
.virtual-pdf-viewer-container {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f5f5;
}
.pdf-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: white;
border-bottom: 1px solid #e8e8e8;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
z-index: 10;
}
.pdf-content {
flex: 1;
overflow: auto;
position: relative;
}
.pdf-virtual-list {
background: #f5f5f5;
}
.pdf-page-wrapper {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background: #f5f5f5;
}
.pdf-page-wrapper canvas {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: white;
margin-bottom: 8px;
}
.pdf-page-number {
margin-top: 8px;
font-size: 13px;
color: #000;
font-weight: 600;
text-align: center;
}
.pdf-page-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 600px;
gap: 8px;
color: #666;
font-size: 14px;
}
.pdf-page-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 600px;
background: #fafafa;
border: 1px dashed #d9d9d9;
color: #999;
font-size: 14px;
}
.pdf-page-skeleton {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
min-height: 800px;
}
.pdf-page-error {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background: white;
border: 1px solid #f0f0f0;
border-radius: 4px;
margin: 20px;
}
.pdf-page-error p {
color: #ff4d4f;
font-size: 14px;
}
.pdf-loading {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
min-height: 400px;
}
.pdf-error {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 400px;
color: #ff4d4f;
font-size: 16px;
}
/* 文本层样式优化 */
.react-pdf__Page__textContent {
user-select: text;
}
/* 注释层样式优化 */
.react-pdf__Page__annotations {
user-select: none;
}
/* Document容器样式 - 确保不限制高度 */
.react-pdf__Document {
height: 100%;
width: 100%;
}

View File

@ -0,0 +1,271 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import { Button, Space, InputNumber, message, Spin } from 'antd'
import {
ZoomInOutlined,
ZoomOutOutlined,
VerticalAlignTopOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import './VirtualPDFViewer.css'
// PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
function VirtualPDFViewer({ url, filename }) {
const [numPages, setNumPages] = useState(null)
const [scale, setScale] = useState(1.0)
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // A4
const [currentPage, setCurrentPage] = useState(1)
const [visiblePages, setVisiblePages] = useState(new Set([1]))
const containerRef = useRef(null)
const pageRefs = useRef({})
// 使 useMemo
const fileConfig = useMemo(() => ({ url }), [url])
// Memoize PDF.js options to prevent unnecessary reloads
const pdfOptions = useMemo(() => ({
cMapUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/cmaps/',
cMapPacked: true,
standardFontDataUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/standard_fonts/',
}), [])
// PDF
const pageHeight = useMemo(() => {
// PDF + padding (40px) + (20px)
return Math.ceil(pdfOriginalSize.height * scale) + 60
}, [scale, pdfOriginalSize.height])
const onDocumentLoadError = (error) => {
console.error('[PDF] Document load error:', error)
message.error('PDF文件加载失败')
}
// Handle scroll to update visible pages
const handleScroll = useCallback(() => {
if (!containerRef.current || !numPages) return
const container = containerRef.current
const scrollTop = container.scrollTop
const containerHeight = container.clientHeight
// Calculate which pages are visible
// Add small tolerance (1px) to handle browser scroll precision issues
const pageIndex = scrollTop / pageHeight
let firstVisiblePage = Math.max(1, Math.ceil(pageIndex + 0.001))
// Special case: if scrolled to bottom, show last page
const isAtBottom = scrollTop + containerHeight >= container.scrollHeight - 1
if (isAtBottom) {
firstVisiblePage = numPages
}
const lastVisiblePage = Math.min(numPages, Math.ceil((scrollTop + containerHeight) / pageHeight))
// Add buffer pages (2 before and 2 after)
const newVisiblePages = new Set()
for (let i = Math.max(1, firstVisiblePage - 2); i <= Math.min(numPages, lastVisiblePage + 2); i++) {
newVisiblePages.add(i)
}
setVisiblePages(newVisiblePages)
setCurrentPage(firstVisiblePage)
}, [numPages, pageHeight])
const onDocumentLoadSuccess = useCallback(async (pdf) => {
setNumPages(pdf.numPages)
try {
//
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 1.0 })
const { width, height } = viewport
setPdfOriginalSize({ width, height })
// PDF
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth - 40 //
if (width > containerWidth) {
const autoScale = Math.floor((containerWidth / width) * 10) / 10 //
setScale(Math.min(Math.max(autoScale, 0.5), 1.0)) // 1.0
} else {
setScale(1.0) // 100%
}
}
} catch (err) {
console.error('Error calculating initial scale:', err)
}
// Initially show first 3 pages
setVisiblePages(new Set([1, 2, 3]))
// Trigger scroll calculation after a short delay to ensure DOM is ready
setTimeout(() => {
handleScroll()
}, 200)
}, [handleScroll])
// Attach scroll listener
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [handleScroll, numPages, pageHeight])
const zoomIn = () => {
setScale((prev) => Math.min(prev + 0.2, 3.0))
}
const zoomOut = () => {
setScale((prev) => Math.max(prev - 0.2, 0.5))
}
const handlePageChange = (value) => {
if (value >= 1 && value <= numPages && containerRef.current) {
const scrollTop = (value - 1) * pageHeight
const container = containerRef.current
container.scrollTo({ top: scrollTop, behavior: 'auto' })
// Manually trigger handleScroll after scrolling to ensure page number updates
setTimeout(() => {
handleScroll()
}, 50)
}
}
const scrollToTop = () => {
if (containerRef.current) {
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
return (
<div className="virtual-pdf-viewer-container">
{/* 工具栏 */}
<div className="pdf-toolbar">
<Space>
<Button
icon={<LeftOutlined />}
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
size="small"
/>
<Space.Compact>
<InputNumber
min={1}
max={numPages || 1}
value={currentPage}
onChange={handlePageChange}
size="small"
style={{ width: 60 }}
/>
<Button size="small" disabled>
/ {numPages || 0}
</Button>
</Space.Compact>
<Button
icon={<RightOutlined />}
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= (numPages || 0)}
size="small"
/>
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollToTop}
size="small"
disabled={currentPage === 1}
>
回到顶部
</Button>
</Space>
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
缩小
</Button>
<span style={{ minWidth: 50, textAlign: 'center' }}>
{Math.round(scale * 100)}%
</span>
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
放大
</Button>
</Space>
</div>
{/* PDF内容区 - 自定义虚拟滚动 */}
<div className="pdf-content" ref={containerRef}>
<Document
file={fileConfig}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={
<div className="pdf-loading">
<Spin size="large" />
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
</div>
}
error={<div className="pdf-error">PDF加载失败请稍后重试</div>}
options={pdfOptions}
>
{numPages && (
<div style={{ height: numPages * pageHeight, position: 'relative' }}>
{Array.from({ length: numPages }, (_, index) => {
const pageNumber = index + 1
const isVisible = visiblePages.has(pageNumber)
return (
<div
key={pageNumber}
ref={el => pageRefs.current[pageNumber] = el}
className="pdf-page-wrapper"
style={{
position: 'absolute',
top: index * pageHeight,
left: 0,
right: 0,
height: pageHeight,
}}
>
{isVisible ? (
<>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
loading={
<div className="pdf-page-loading">
<Spin size="small" />
<div>加载第 {pageNumber} ...</div>
</div>
}
/>
<div className="pdf-page-number"> {pageNumber} </div>
</>
) : (
<div className="pdf-page-placeholder">
<div> {pageNumber} </div>
</div>
)}
</div>
)
})}
</div>
)}
</Document>
</div>
</div>
)
}
export default VirtualPDFViewer

View File

@ -1,6 +1,5 @@
/* 覆盖MainLayout的content-wrapper padding */
.document-editor-page {
height: calc(90vh);
height: calc(100vh - 64px);
/* width: calc(100% + 32px); */
display: flex;
}
@ -14,44 +13,84 @@
.document-sider {
border-right: 1px solid #f0f0f0;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
flex-shrink: 0;
}
.sider-header {
padding: 16px;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
gap: 12px;
background: #fff;
}
.sider-header h3 {
.sider-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
line-height: 1.5;
}
.sider-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.sider-actions .ant-btn {
.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;
border: none !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.mode-toggle-btn.exit-edit {
background: linear-gradient(135deg, #00b96b 0%, #52c41a 100%) !important;
color: white !important;
box-shadow: 0 2px 6px rgba(0, 185, 107, 0.2);
}
.mode-toggle-btn:hover {
transform: translateY(-1px);
filter: brightness(1.05);
}
.mode-toggle-btn.exit-edit:hover {
box-shadow: 0 4px 10px rgba(0, 185, 107, 0.3);
}
.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;
width: 32px;
height: 32px;
color: rgba(0, 0, 0, 0.65);
background: #f5f5f5;
border: 1px solid #e8e8e8;
}
.sider-actions .ant-btn:hover {
color: #1890ff;
background: #e6f7ff;
border-color: #91d5ff;
.sider-actions .ant-btn:not(.mode-toggle-btn):hover {
background: #fff;
border-color: #d9d9d9;
color: #1677ff;
}
.file-tree {
padding: 8px;
flex: 1;
overflow-y: auto;
}
/* 修复Tree组件文档名过长的显示问题 */
@ -74,7 +113,8 @@
/* 确保Tree节点标题区域不折行 */
.file-tree .ant-tree-node-content-wrapper .ant-tree-title {
display: inline-block;
max-width: calc(100% - 24px); /* 预留图标空间 */
max-width: calc(100% - 24px);
/* 预留图标空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
@ -86,7 +126,8 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1; /* Ensure it allows children to fill width */
flex: 1;
/* Ensure it allows children to fill width */
}
/* Increase hit area for context menu */
@ -102,19 +143,20 @@
}
/* 选中的文件夹样式 */
.file-tree .folder-selected > .ant-menu-submenu-title {
.file-tree .folder-selected>.ant-menu-submenu-title {
background-color: #e6f7ff !important;
color: #1890ff !important;
}
.file-tree .folder-selected > .ant-menu-submenu-title:hover {
.file-tree .folder-selected>.ant-menu-submenu-title:hover {
background-color: #bae7ff !important;
}
.file-tree .ant-menu-item,
.file-tree .ant-menu-submenu-title {
overflow: hidden;
display: flex !important; /* Ensure flex layout for item */
display: flex !important;
/* Ensure flex layout for item */
align-items: center;
}
@ -160,7 +202,8 @@
.bytemd-wrapper {
flex: 1;
display: flex;
flex-direction: column; /* Changed to column to force child stretch width */
flex-direction: column;
/* Changed to column to force child stretch width */
min-height: 0;
min-width: 0;
overflow: hidden;
@ -170,7 +213,7 @@
}
/* Fix for bytemd-react wrapper div */
.bytemd-wrapper > div {
.bytemd-wrapper>div {
flex: 1;
display: flex;
flex-direction: column;
@ -196,7 +239,8 @@
border: 1px solid #d9d9d9;
border-radius: 2px;
overflow: hidden;
max-width: none !important; /* Ensure no max-width constraint */
max-width: none !important;
/* Ensure no max-width constraint */
box-sizing: border-box;
}
@ -205,7 +249,8 @@
flex-shrink: 0;
border-bottom: 1px solid #d9d9d9;
background-color: #fafafa;
box-sizing: border-box; /* Added for consistent box model */
box-sizing: border-box;
/* Added for consistent box model */
}
/* 编辑和预览区域容器 */
@ -215,8 +260,10 @@
overflow: hidden;
min-height: 0;
width: 100% !important;
box-sizing: border-box; /* Added for consistent box model */
min-width: 0; /* Prevent flex item from overflowing */
box-sizing: border-box;
/* Added for consistent box model */
min-width: 0;
/* Prevent flex item from overflowing */
}
/* 编辑区域 - 固定50%宽度 */
@ -228,8 +275,10 @@
overflow: hidden;
min-height: 0;
max-width: 50% !important;
box-sizing: border-box; /* Added for consistent box model */
min-width: 0; /* Prevent flex item from overflowing */
box-sizing: border-box;
/* Added for consistent box model */
min-width: 0;
/* Prevent flex item from overflowing */
}
/* 预览区域 - 固定50%宽度 */
@ -242,8 +291,10 @@
font-size: 14px;
line-height: 1.8;
max-width: 50% !important;
box-sizing: border-box; /* Added for consistent box model */
min-width: 0; /* Prevent flex item from overflowing */
box-sizing: border-box;
/* Added for consistent box model */
min-width: 0;
/* Prevent flex item from overflowing */
}
/* CodeMirror 容器 */

View File

@ -63,6 +63,7 @@ function DocumentEditor() {
const [openKeys, setOpenKeys] = useState([]) // Menu
const [uploadProgress, setUploadProgress] = useState(0) //
const [uploading, setUploading] = useState(false) //
const [fileList, setFileList] = useState([]) //
const [selectedMenuKey, setSelectedMenuKey] = useState(null) //
const uploadingRef = useRef(false) // 使ref
const [isPdfSelected, setIsPdfSelected] = useState(false) // PDF
@ -206,7 +207,7 @@ function DocumentEditor() {
// If clicked from context menu (node is passed), use it
const targetNode = (node && node.key) ? node : selectedNode
const parentPath = getParentPath(targetNode)
setCreationParentPath(parentPath)
setOperationType('create_file')
setModalVisible(true)
@ -370,15 +371,19 @@ function DocumentEditor() {
return
}
const { fileList } = info
const { fileList: currentFileList } = info
// state
setFileList(currentFileList)
//
const mdFiles = fileList.filter((f) => f.name.endsWith('.md'))
const pdfFiles = fileList.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
const mdFiles = currentFileList.filter((f) => f.name.endsWith('.md'))
const pdfFiles = currentFileList.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
const allFiles = [...mdFiles, ...pdfFiles]
if (allFiles.length === 0) {
Toast.warning('提示', '请选择.md或.pdf格式的文档')
setFileList([]) //
return
}
@ -457,6 +462,7 @@ function DocumentEditor() {
Toast.success('成功', `成功上传 ${successCount} 个文档`)
fetchTree()
//
setFileList([])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
@ -776,23 +782,24 @@ function DocumentEditor() {
return (
<div className="document-editor-page">
<Layout className="document-editor-container">
<Layout className="document-editor-container">
<Sider
width={280}
theme="light"
className="document-sider"
>
<div className="sider-header">
<h3>项目文档编辑模式</h3>
<h2>项目文档</h2>
<div className="sider-actions">
<Tooltip title="返回浏览">
<Button
type="text"
size="middle"
icon={<EyeOutlined />}
onClick={() => navigate(`/projects/${projectId}/docs`)}
/>
</Tooltip>
<Button
type="primary"
size="small"
className="mode-toggle-btn exit-edit"
icon={<EyeOutlined />}
onClick={() => navigate(`/projects/${projectId}/docs`)}
>
退出编辑
</Button>
<Tooltip title="添加文件">
<Button
type="text"
@ -813,6 +820,7 @@ function DocumentEditor() {
multiple
accept=".md,.pdf,application/pdf"
showUploadList={false}
fileList={fileList}
beforeUpload={() => false}
onChange={handleImportDocuments}
>
@ -913,10 +921,10 @@ function DocumentEditor() {
<Modal
title={
operationType === 'create_file'
? `创建文件 (dir: /${creationParentPath || '/'})`
? `创建文件 (dir: /${creationParentPath || ''})`
: operationType === 'create_dir'
? `创建文件夹 (dir: /${creationParentPath || '/'})`
: '重命名'
? `创建文件夹 (dir: /${creationParentPath || ''})`
: '重命名'
}
open={modalVisible}
onOk={handleOperation}
@ -929,8 +937,8 @@ function DocumentEditor() {
<Input
placeholder={
operationType === 'create_file' ? '文件名(如:新文档.md' :
operationType === 'create_dir' ? '文件夹名' :
'新名称'
operationType === 'create_dir' ? '文件夹名' :
'新名称'
}
value={newName}
onChange={(e) => setNewName(e.target.value)}

View File

@ -17,41 +17,61 @@
}
.docs-sider-header {
padding: 16px;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
gap: 12px;
background: #fff;
}
.docs-sider-header h2 {
margin: 0;
font-size: 18px;
font-size: 16px;
font-weight: 600;
color: #262626;
line-height: 1.5;
}
.docs-sider-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.docs-sider-actions .ant-btn {
background: #f5f5f5;
border: 1px solid #e8e8e8;
.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;
border: none !important;
height: 32px !important;
display: flex !important;
align-items: center !important;
padding: 0 12px !important;
}
.docs-sider-actions .ant-btn-text {
color: rgba(0, 0, 0, 0.65);
.mode-toggle-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(22, 119, 255, 0.3);
filter: brightness(1.1);
}
.docs-sider-actions .ant-btn:hover {
border-color: #91d5ff;
.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;
width: 32px;
height: 32px;
}
.docs-sider-actions .ant-btn-text:hover {
color: #1890ff;
background: #e6f7ff;
background: #e6f4ff;
border-color: #91caff;
color: #1677ff;
}
.docs-menu {
@ -112,7 +132,8 @@
.toc-content .ant-anchor {
padding-left: 0;
padding-bottom: 65px; /* 给Anchor组件添加底部内边距避免最后一项被遮挡 */
padding-bottom: 65px;
/* 给Anchor组件添加底部内边距避免最后一项被遮挡 */
}
.toc-content .ant-anchor-link {
@ -126,7 +147,7 @@
white-space: nowrap;
}
.toc-content .ant-anchor-link-active > .ant-anchor-link-title {
.toc-content .ant-anchor-link-active>.ant-anchor-link-title {
color: #1890ff;
font-weight: 500;
}
@ -278,4 +299,4 @@
.markdown-body li {
margin-bottom: 4px;
}
}

View File

@ -10,7 +10,7 @@ import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import PDFViewer from '@/components/PDFViewer/PDFViewer'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import './DocumentPage.css'
const { Sider, Content } = Layout
@ -332,203 +332,204 @@ function DocumentPage() {
return (
<div className="project-docs-page">
<Layout className="docs-layout">
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
<h2>项目文档浏览模式</h2>
<div className="docs-sider-actions">
{/* 只有 owner/admin/editor 可以编辑 */}
{userRole !== 'viewer' && (
<Tooltip title="编辑模式">
<Button
type="link"
size="middle"
icon={<EditOutlined />}
onClick={handleEdit}
/>
</Tooltip>
)}
<Tooltip title="分享">
<Button
type="text"
size="middle"
icon={<ShareAltOutlined />}
onClick={handleShare}
/>
</Tooltip>
<Tooltip title="设置">
<Button
type="text"
size="middle"
icon={<SettingOutlined />}
onClick={() => message.info('设置功能开发中')}
/>
</Tooltip>
</div>
{/* 左侧目录 */}
<Sider width={280} className="docs-sider" theme="light">
<div className="docs-sider-header">
<h2>项目文档</h2>
<div className="docs-sider-actions">
{/* 只有 owner/admin/editor 可以编辑 */}
{userRole !== 'viewer' && (
<Button
type="primary"
size="small"
className="mode-toggle-btn"
icon={<EditOutlined />}
onClick={handleEdit}
>
编辑模式
</Button>
)}
<Tooltip title="分享">
<Button
type="text"
size="middle"
icon={<ShareAltOutlined />}
onClick={handleShare}
/>
</Tooltip>
<Tooltip title="设置">
<Button
type="text"
size="middle"
icon={<SettingOutlined />}
onClick={() => message.info('设置功能开发中')}
/>
</Tooltip>
</div>
</div>
<Menu
mode="inline"
selectedKeys={[selectedFile]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
className="docs-menu"
/>
</Sider>
{/* 右侧内容区 */}
<Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? (
<div className="docs-loading">
<Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div>
) : viewMode === 'pdf' ? (
<VirtualPDFViewer
url={pdfUrl}
filename={pdfFilename}
/>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
components={{
a: ({ node, href, children, ...props }) => (
<a
href={href}
onClick={(e) => handleMarkdownLink(e, href)}
{...props}
>
{children}
</a>
),
}}
>
{markdownContent}
</ReactMarkdown>
</div>
)}
</div>
<Menu
mode="inline"
selectedKeys={[selectedFile]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
className="docs-menu"
/>
</Sider>
{/* 返回顶部按钮 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && (
<FloatButton
icon={<VerticalAlignTopOutlined />}
type="primary"
style={{ right: tocCollapsed ? 24 : 280 }}
onClick={() => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}}
/>
)}
</Content>
{/* 右侧内容区 */}
<Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? (
<div className="docs-loading">
<Spin size="large">
<div style={{ marginTop: 16 }}>加载中...</div>
</Spin>
</div>
) : viewMode === 'pdf' ? (
<PDFViewer
url={pdfUrl}
filename={pdfFilename}
{/* 右侧TOC面板 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && !tocCollapsed && (
<Sider width={250} theme="light" className="docs-toc-sider">
<div className="toc-header">
<h3>文档索引</h3>
<Button
type="text"
size="small"
icon={<MenuFoldOutlined />}
onClick={() => setTocCollapsed(true)}
/>
</div>
<div className="toc-content">
{tocItems.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={() => contentRef.current}
items={tocItems.map((item) => ({
key: item.key,
href: item.href,
title: (
<div style={{ paddingLeft: `${(item.level - 1) * 12}px`, display: 'flex', alignItems: 'center', gap: '4px' }}>
<FileTextOutlined style={{ fontSize: '12px', color: '#8c8c8c' }} />
{item.title}
</div>
),
}))}
/>
) : (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
components={{
a: ({ node, href, children, ...props }) => (
<a
href={href}
onClick={(e) => handleMarkdownLink(e, href)}
{...props}
>
{children}
</a>
),
}}
>
{markdownContent}
</ReactMarkdown>
</div>
<div className="toc-empty">当前文档无标题</div>
)}
</div>
{/* 返回顶部按钮 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && (
<FloatButton
icon={<VerticalAlignTopOutlined />}
type="primary"
style={{ right: tocCollapsed ? 24 : 280 }}
onClick={() => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}}
/>
)}
</Content>
{/* 右侧TOC面板 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && !tocCollapsed && (
<Sider width={250} theme="light" className="docs-toc-sider">
<div className="toc-header">
<h3>文档索引</h3>
<Button
type="text"
size="small"
icon={<MenuFoldOutlined />}
onClick={() => setTocCollapsed(true)}
/>
</div>
<div className="toc-content">
{tocItems.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={() => contentRef.current}
items={tocItems.map((item) => ({
key: item.key,
href: item.href,
title: (
<div style={{ paddingLeft: `${(item.level - 1) * 12}px`, display: 'flex', alignItems: 'center', gap: '4px' }}>
<FileTextOutlined style={{ fontSize: '12px', color: '#8c8c8c' }} />
{item.title}
</div>
),
}))}
/>
) : (
<div className="toc-empty">当前文档无标题</div>
)}
</div>
</Sider>
)}
</Layout>
{/* TOC展开按钮 */}
{tocCollapsed && (
<Button
type="primary"
icon={<MenuUnfoldOutlined />}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
</Button>
</Sider>
)}
</Layout>
{/* 分享模态框 */}
<Modal
title="分享项目"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* TOC展开按钮 */}
{tocCollapsed && (
<Button
type="primary"
icon={<MenuUnfoldOutlined />}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
</Button>
)}
</Layout>
{/* 分享模态框 */}
<Modal
title="分享项目"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>分享链接</label>
<Input
value={`${window.location.origin}${shareInfo.share_url}`}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
}
/>
</div>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>分享链接</label>
<Input
value={`${window.location.origin}${shareInfo.share_url}`}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
}
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
</div>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
<div>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
</div>
)}
</Space>
)}
</Modal>
</div>
)}
</Space>
)}
</Modal>
</div>
)
}

View File

@ -9,7 +9,7 @@ import rehypeSlug from 'rehype-slug'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css'
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share'
import PDFViewer from '@/components/PDFViewer/PDFViewer'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import './PreviewPage.css'
const { Sider, Content } = Layout
@ -316,7 +316,7 @@ function PreviewPage() {
</Spin>
</div>
) : viewMode === 'pdf' ? (
<PDFViewer
<VirtualPDFViewer
url={pdfUrl}
filename={pdfFilename}
/>

View File

@ -307,85 +307,85 @@ function ProjectList({ type = 'my' }) {
// 使
const filteredProjects = !hasSearched && searchKeyword
? projects.filter((project) =>
project.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
(project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase()))
)
project.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
(project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase()))
)
: projects
return (
<div className="project-list-container">
<ListActionBar
actions={type === 'my' ? [
{
key: 'create',
label: '创建项目',
type: 'primary',
icon: <PlusOutlined />,
onClick: () => setModalVisible(true),
},
] : []}
search={{
placeholder: '搜索项目或文件...',
value: searchKeyword,
onChange: handleSearchChange,
onSearch: handleSearch,
}}
showRefresh
onRefresh={fetchProjects}
/>
<ListActionBar
actions={type === 'my' ? [
{
key: 'create',
label: '创建项目',
type: 'primary',
icon: <PlusOutlined />,
onClick: () => setModalVisible(true),
},
] : []}
search={{
placeholder: '搜索项目或文件...',
value: searchKeyword,
onChange: handleSearchChange,
onSearch: handleSearch,
}}
showRefresh
onRefresh={fetchProjects}
/>
{/* 搜索结果 */}
{hasSearched && searchResults.length > 0 && (
<div style={{ marginTop: 16, marginBottom: 16 }}>
<div style={{ marginBottom: 8, color: '#666' }}>
找到 {searchResults.length} 个结果
</div>
<Row gutter={[16, 16]}>
{searchResults.map((item, index) => (
<Col xs={24} sm={12} md={8} lg={6} key={`${item.type}-${item.project_id}-${index}`}>
<Card
hoverable
className="project-card"
onClick={() => handleSearchResultClick(item)}
>
<div className="project-card-icon">
{item.type === 'project' ? (
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
) : (
<FileOutlined style={{ fontSize: 48, color: '#52c41a' }} />
)}
</div>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<h3 style={{ margin: 0 }}>
{item.type === 'project' ? item.project_name : item.file_name}
</h3>
<Tag color={item.type === 'project' ? 'blue' : 'green'}>
{item.match_type}
</Tag>
</Space>
{item.type === 'project' && (
<p className="project-description">
{item.project_description || '暂无描述'}
</p>
)}
{item.type === 'file' && (
<div style={{ fontSize: 12, color: '#666' }}>
<div>项目: {item.project_name}</div>
<div>路径: {item.file_path}</div>
</div>
)}
</Space>
</Card>
</Col>
))}
</Row>
{/* 搜索结果 */}
{hasSearched && searchResults.length > 0 && (
<div style={{ marginTop: 16, marginBottom: 16 }}>
<div style={{ marginBottom: 8, color: '#666' }}>
找到 {searchResults.length} 个结果
</div>
)}
<Row gutter={[16, 16]}>
{searchResults.map((item, index) => (
<Col xs={24} sm={12} md={8} lg={6} key={`${item.type}-${item.project_id}-${index}`}>
<Card
hoverable
className="project-card"
onClick={() => handleSearchResultClick(item)}
>
<div className="project-card-icon">
{item.type === 'project' ? (
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
) : (
<FileOutlined style={{ fontSize: 48, color: '#52c41a' }} />
)}
</div>
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<h3 style={{ margin: 0 }}>
{item.type === 'project' ? item.project_name : item.file_name}
</h3>
<Tag color={item.type === 'project' ? 'blue' : 'green'}>
{item.match_type}
</Tag>
</Space>
{item.type === 'project' && (
<p className="project-description">
{item.project_description || '暂无描述'}
</p>
)}
{item.type === 'file' && (
<div style={{ fontSize: 12, color: '#666' }}>
<div>项目: {item.project_name}</div>
<div>路径: {item.file_path}</div>
</div>
)}
</Space>
</Card>
</Col>
))}
</Row>
</div>
)}
{/* 正常项目列表 */}
{!hasSearched && (
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{/* 正常项目列表 */}
{!hasSearched && (
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
{filteredProjects.map((project) => (
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
<Card
@ -433,327 +433,327 @@ function ProjectList({ type = 'my' }) {
</Col>
)}
</Row>
)}
)}
{/* 搜索无结果提示 */}
{hasSearched && !searching && searchResults.length === 0 && (
<div style={{ marginTop: 16 }}>
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
</div>
)}
{/* 搜索无结果提示 */}
{hasSearched && !searching && searchResults.length === 0 && (
<div style={{ marginTop: 16 }}>
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
</div>
)}
<Modal
title="创建新项目"
open={modalVisible}
onCancel={() => {
setModalVisible(false)
form.resetFields()
}}
footer={null}
<Modal
title="创建新项目"
open={modalVisible}
onCancel={() => {
setModalVisible(false)
form.resetFields()
}}
footer={null}
>
<Form
form={form}
layout="vertical"
onFinish={handleCreateProject}
>
<Form
form={form}
layout="vertical"
onFinish={handleCreateProject}
<Form.Item
label="项目名称"
name="name"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Form.Item
label="项目名称"
name="name"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" />
</Form.Item>
<Input placeholder="请输入项目名称" />
</Form.Item>
<Form.Item
label="项目描述"
name="description"
>
<Input.TextArea
rows={4}
placeholder="请输入项目描述(选填)"
/>
</Form.Item>
<Form.Item
label="项目描述"
name="description"
>
<Input.TextArea
rows={4}
placeholder="请输入项目描述(选填)"
/>
</Form.Item>
<Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
创建
</Button>
<Button onClick={() => {
setModalVisible(false)
form.resetFields()
}}>
取消
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑项目"
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false)
editForm.resetFields()
}}
footer={null}
>
<Form
form={editForm}
layout="vertical"
onFinish={handleUpdateProject}
>
<Form.Item
label="项目名称"
name="name"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" />
</Form.Item>
<Form.Item
label="项目描述"
name="description"
>
<Input.TextArea
rows={4}
placeholder="请输入项目描述(选填)"
/>
</Form.Item>
<Form.Item
label="公开项目"
name="is_public"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
handleDeleteProject(currentProject.id)
}}
>
删除项目
</Button>
<Space>
<Button type="primary" htmlType="submit">
创建
更新
</Button>
<Button onClick={() => {
setModalVisible(false)
form.resetFields()
setEditModalVisible(false)
editForm.resetFields()
}}>
取消
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑项目"
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false)
editForm.resetFields()
}}
footer={null}
>
<Form
form={editForm}
layout="vertical"
onFinish={handleUpdateProject}
>
<Form.Item
label="项目名称"
name="name"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" />
</Form.Item>
<Form.Item
label="项目描述"
name="description"
>
<Input.TextArea
rows={4}
placeholder="请输入项目描述(选填)"
/>
</Form.Item>
<Form.Item
label="公开项目"
name="is_public"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
handleDeleteProject(currentProject.id)
}}
>
删除项目
</Button>
<Space>
<Button type="primary" htmlType="submit">
更新
</Button>
<Button onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
}}>
取消
</Button>
</Space>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="分享项目"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>分享链接</label>
<Input
value={`${window.location.origin}${shareInfo.share_url}`}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
}
/>
</div>
{/* 只有在我的项目中才显示密码设置功能 */}
{type === 'my' && (
<>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
<div>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
</div>
)}
</>
)}
{/* 参与项目显示提示 */}
{type === 'share' && shareInfo.has_password && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
该项目已设置访问密码保护
</div>
)}
</Space>
)}
</Modal>
</Form.Item>
</Form>
</Modal>
<Modal
title="成员管理"
open={membersModalVisible}
onCancel={() => {
setMembersModalVisible(false)
memberForm.resetFields()
setMembers([])
setUsers([])
}}
footer={null}
width={700}
>
<Modal
title="分享设置"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
{users.length === 0 ? (
<div style={{ marginBottom: 16, color: '#999' }}>
{loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'}
</div>) : null}
<Form
form={memberForm}
layout="inline"
onFinish={handleAddMember}
>
<Form.Item
name="user_id"
rules={[{ required: true, message: '请选择用户' }]}
style={{ width: 250 }}
>
<Select
placeholder={users.length > 0 ? "选择用户" : "没有可添加的用户"}
showSearch
loading={loadingMembers}
disabled={users.length === 0}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={users
.filter(user => !members.some(m => m.user_id === user.id))
.map(user => ({
value: user.id,
label: user.nickname ? `${user.username} (${user.nickname})` : user.username,
}))}
notFoundContent={loadingMembers ? "加载中..." : "没有找到用户"}
/>
</Form.Item>
<Form.Item
name="role"
rules={[{ required: true, message: '请选择角色' }]}
initialValue="viewer"
style={{ width: 150 }}
>
<Select placeholder="选择角色">
<Select.Option value="admin">管理员</Select.Option>
<Select.Option value="editor">编辑者</Select.Option>
<Select.Option value="viewer">查看者</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loadingMembers}
disabled={users.length === 0}
>
添加
</Button>
</Form.Item>
</Form>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>链接</label>
<Input
value={`${window.location.origin}${shareInfo.share_url}`}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
}
/>
</div>
<div>
<h4>当前成员</h4>
{members.length === 0 ? (
<Empty description="暂无成员" />
) : (
<Table
dataSource={members}
rowKey="id"
pagination={false}
loading={loadingMembers}
columns={[
{
title: '用户',
dataIndex: 'username',
key: 'username',
render: (username, record) => {
if (record.nickname) {
return `${username} (${record.nickname})`
}
return username || `用户ID: ${record.user_id}`
},
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role) => {
const roleMap = {
admin: '管理员',
editor: '编辑者',
viewer: '查看者',
}
return roleMap[role] || role
},
},
{
title: '加入时间',
dataIndex: 'joined_at',
key: 'joined_at',
render: (time) => time ? new Date(time).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
key: 'action',
render: (_, record) => {
const isOwner = record.user_id === currentProject?.owner_id
return (
<Button
type="link"
danger
onClick={() => handleRemoveMember(record.user_id)}
disabled={isOwner}
title={isOwner ? '不能删除项目所有者' : '删除成员'}
>
{isOwner ? '所有者' : '删除'}
</Button>
)
},
},
]}
/>
)}
</div>
{/* 只有在我的项目中才显示密码设置功能 */}
{type === 'my' && (
<>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
<div>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
</div>
)}
</>
)}
{/* 参与项目显示提示 */}
{type === 'share' && shareInfo.has_password && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
该项目已设置访问密码保护
</div>
)}
</Space>
</Modal>
</div>
)}
</Modal>
<Modal
title="成员管理"
open={membersModalVisible}
onCancel={() => {
setMembersModalVisible(false)
memberForm.resetFields()
setMembers([])
setUsers([])
}}
footer={null}
width={700}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
{users.length === 0 ? (
<div style={{ marginBottom: 16, color: '#999' }}>
{loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'}
</div>) : null}
<Form
form={memberForm}
layout="inline"
onFinish={handleAddMember}
>
<Form.Item
name="user_id"
rules={[{ required: true, message: '请选择用户' }]}
style={{ width: 250 }}
>
<Select
placeholder={users.length > 0 ? "选择用户" : "没有可添加的用户"}
showSearch
loading={loadingMembers}
disabled={users.length === 0}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={users
.filter(user => !members.some(m => m.user_id === user.id))
.map(user => ({
value: user.id,
label: user.nickname ? `${user.username} (${user.nickname})` : user.username,
}))}
notFoundContent={loadingMembers ? "加载中..." : "没有找到用户"}
/>
</Form.Item>
<Form.Item
name="role"
rules={[{ required: true, message: '请选择角色' }]}
initialValue="viewer"
style={{ width: 150 }}
>
<Select placeholder="选择角色">
<Select.Option value="admin">管理员</Select.Option>
<Select.Option value="editor">编辑者</Select.Option>
<Select.Option value="viewer">查看者</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loadingMembers}
disabled={users.length === 0}
>
添加
</Button>
</Form.Item>
</Form>
</div>
<div>
<h4>当前成员</h4>
{members.length === 0 ? (
<Empty description="暂无成员" />
) : (
<Table
dataSource={members}
rowKey="id"
pagination={false}
loading={loadingMembers}
columns={[
{
title: '用户',
dataIndex: 'username',
key: 'username',
render: (username, record) => {
if (record.nickname) {
return `${username} (${record.nickname})`
}
return username || `用户ID: ${record.user_id}`
},
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role) => {
const roleMap = {
admin: '管理员',
editor: '编辑者',
viewer: '查看者',
}
return roleMap[role] || role
},
},
{
title: '加入时间',
dataIndex: 'joined_at',
key: 'joined_at',
render: (time) => time ? new Date(time).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
key: 'action',
render: (_, record) => {
const isOwner = record.user_id === currentProject?.owner_id
return (
<Button
type="link"
danger
onClick={() => handleRemoveMember(record.user_id)}
disabled={isOwner}
title={isOwner ? '不能删除项目所有者' : '删除成员'}
>
{isOwner ? '所有者' : '删除'}
</Button>
)
},
},
]}
/>
)}
</div>
</Space>
</Modal>
</div>
)
}

View File

@ -4504,6 +4504,16 @@ react-router@6.30.2:
dependencies:
"@remix-run/router" "1.23.1"
react-virtuoso@^4.18.1:
version "4.18.1"
resolved "https://registry.npmmirror.com/react-virtuoso/-/react-virtuoso-4.18.1.tgz#3eb7078f2739a31b96c723374019e587deeb6ebc"
integrity sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==
react-window@^2.2.3:
version "2.2.3"
resolved "https://registry.npmmirror.com/react-window/-/react-window-2.2.3.tgz#f8ffdddbb612ccd3e1314b59fce79af85d3f15e3"
integrity sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==
react@^18.2.0:
version "18.3.1"
resolved "https://registry.npmmirror.com/react/-/react-18.3.1.tgz"