fix project role permission
parent
5d41339c4a
commit
ba80d280d1
|
|
@ -13,7 +13,7 @@ import secrets
|
|||
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.models.project import Project, ProjectMember, ProjectMemberRole
|
||||
from app.models.git_repo import ProjectGitRepo
|
||||
from app.models.share import ShareLink
|
||||
from app.schemas.project import (
|
||||
|
|
@ -30,6 +30,7 @@ from app.services.storage import storage_service
|
|||
from app.services.log_service import log_service
|
||||
from app.services.git_service import git_service
|
||||
from app.services.notification_service import notification_service
|
||||
from app.services.project_service import normalize_project_role, require_project_roles
|
||||
from app.core.enums import OperationType, ResourceType
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -206,7 +207,7 @@ async def get_shared_projects(
|
|||
project_dict = ProjectResponse.from_orm(project).dict()
|
||||
project_dict['owner_name'] = owner.username
|
||||
project_dict['owner_nickname'] = owner.nickname
|
||||
project_dict['user_role'] = member.role # 添加用户角色
|
||||
project_dict['user_role'] = normalize_project_role(member.role)
|
||||
doc_count, last_activity_at = get_project_document_stats(project.storage_key)
|
||||
project_dict['doc_count'] = doc_count
|
||||
project_dict['last_activity_at'] = last_activity_at
|
||||
|
|
@ -581,7 +582,7 @@ async def get_project_members(
|
|||
"id": member.id,
|
||||
"project_id": member.project_id,
|
||||
"user_id": member.user_id,
|
||||
"role": member.role,
|
||||
"role": normalize_project_role(member.role),
|
||||
"joined_at": member.joined_at.isoformat() if member.joined_at else None,
|
||||
"username": user.username,
|
||||
"nickname": user.nickname,
|
||||
|
|
@ -599,25 +600,13 @@ async def add_project_member(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""添加项目成员"""
|
||||
# 查询项目
|
||||
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:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role == "admin"
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权添加成员")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=[ProjectMemberRole.ADMIN.value],
|
||||
forbidden_detail="无权添加成员",
|
||||
)
|
||||
|
||||
# 检查用户是否已是成员
|
||||
existing_result = await db.execute(
|
||||
|
|
@ -678,24 +667,13 @@ async def update_project_member_role(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""修改项目成员角色"""
|
||||
# 查询项目
|
||||
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:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role == "admin"
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权修改成员角色")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=[ProjectMemberRole.ADMIN.value],
|
||||
forbidden_detail="无权修改成员角色",
|
||||
)
|
||||
|
||||
# 不能修改项目所有者的角色
|
||||
if project.owner_id == user_id:
|
||||
|
|
@ -741,25 +719,13 @@ async def remove_project_member(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""删除项目成员"""
|
||||
# 查询项目
|
||||
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:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role == "admin"
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权删除成员")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=[ProjectMemberRole.ADMIN.value],
|
||||
forbidden_detail="无权删除成员",
|
||||
)
|
||||
|
||||
# 不能删除项目所有者
|
||||
if user_id == project.owner_id:
|
||||
|
|
@ -822,24 +788,13 @@ async def git_pull(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""执行 Git Pull(sub_path 非空时仅同步指定目录)"""
|
||||
# 查询项目
|
||||
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:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role.in_(['admin', 'editor'])
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权执行Git操作")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=[ProjectMemberRole.ADMIN.value],
|
||||
forbidden_detail="无权执行Git操作",
|
||||
)
|
||||
|
||||
# 获取Git仓库配置
|
||||
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
||||
|
|
@ -925,24 +880,13 @@ async def git_push(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""执行 Git Push(sub_path 非空时仅推送指定目录的变更)"""
|
||||
# 查询项目
|
||||
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:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role.in_(['admin', 'editor'])
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权执行Git操作")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=[ProjectMemberRole.ADMIN.value],
|
||||
forbidden_detail="无权执行Git操作",
|
||||
)
|
||||
|
||||
# 获取Git仓库配置
|
||||
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
||||
|
|
@ -1036,22 +980,13 @@ async def list_git_directories(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""列出指定 Git 仓库远端目录结构(用于选择同步目录)"""
|
||||
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:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.role.in_(["admin", "editor"]),
|
||||
)
|
||||
)
|
||||
if not member_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="无权操作Git仓库")
|
||||
project, _ = await require_project_roles(
|
||||
db,
|
||||
project_id,
|
||||
current_user,
|
||||
allowed_roles=[ProjectMemberRole.ADMIN.value],
|
||||
forbidden_detail="无权操作Git仓库",
|
||||
)
|
||||
|
||||
query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id)
|
||||
if repo_id:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ OWNER_ROLE = "owner"
|
|||
PUBLIC_ROLE = "public"
|
||||
|
||||
|
||||
def normalize_project_role(role: Optional[str]) -> Optional[str]:
|
||||
"""兼容历史数据中的角色大小写差异。"""
|
||||
if role is None:
|
||||
return None
|
||||
if isinstance(role, ProjectMemberRole):
|
||||
return role.value
|
||||
return str(role).strip().lower()
|
||||
|
||||
|
||||
async def get_project_or_404(db: AsyncSession, project_id: int) -> Project:
|
||||
"""获取项目,不存在时抛出 404。"""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
|
|
@ -53,7 +62,7 @@ async def get_project_role(
|
|||
return OWNER_ROLE
|
||||
|
||||
member = await get_project_member(db, project.id, current_user.id)
|
||||
return member.role if member else None
|
||||
return normalize_project_role(member.role) if member else None
|
||||
|
||||
|
||||
async def require_project_read_access(
|
||||
|
|
@ -101,7 +110,12 @@ async def require_project_roles(
|
|||
forbidden_detail=forbidden_detail,
|
||||
)
|
||||
|
||||
if role in {OWNER_ROLE, *allowed_roles}:
|
||||
normalized_allowed_roles = {
|
||||
normalize_project_role(allowed_role)
|
||||
for allowed_role in allowed_roles
|
||||
}
|
||||
|
||||
if role in {OWNER_ROLE, *normalized_allowed_roles}:
|
||||
return project, role
|
||||
|
||||
raise HTTPException(status_code=403, detail=forbidden_detail)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.project_service import (
|
||||
normalize_project_role,
|
||||
require_project_roles,
|
||||
require_project_write_access,
|
||||
)
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class _RecordingDB:
|
||||
def __init__(self, values):
|
||||
self.values = iter(values)
|
||||
|
||||
async def execute(self, statement):
|
||||
return _ScalarResult(next(self.values))
|
||||
|
||||
|
||||
class ProjectPermissionsTest(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.project = SimpleNamespace(id=25, owner_id=1)
|
||||
self.current_user = SimpleNamespace(id=2)
|
||||
|
||||
def test_normalize_project_role_handles_legacy_case(self):
|
||||
self.assertEqual(normalize_project_role(" ADMIN "), "admin")
|
||||
self.assertEqual(normalize_project_role("Editor"), "editor")
|
||||
self.assertIsNone(normalize_project_role(None))
|
||||
|
||||
async def test_write_access_accepts_legacy_uppercase_admin(self):
|
||||
db = _RecordingDB([
|
||||
self.project,
|
||||
SimpleNamespace(role="ADMIN"),
|
||||
])
|
||||
|
||||
project, role = await require_project_write_access(db, 25, self.current_user)
|
||||
|
||||
self.assertEqual(project, self.project)
|
||||
self.assertEqual(role, "admin")
|
||||
|
||||
async def test_write_access_accepts_legacy_uppercase_editor(self):
|
||||
db = _RecordingDB([
|
||||
self.project,
|
||||
SimpleNamespace(role="EDITOR"),
|
||||
])
|
||||
|
||||
project, role = await require_project_write_access(db, 25, self.current_user)
|
||||
|
||||
self.assertEqual(project, self.project)
|
||||
self.assertEqual(role, "editor")
|
||||
|
||||
async def test_write_access_rejects_viewer(self):
|
||||
db = _RecordingDB([
|
||||
self.project,
|
||||
SimpleNamespace(role="viewer"),
|
||||
])
|
||||
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
await require_project_write_access(db, 25, self.current_user)
|
||||
|
||||
self.assertEqual(context.exception.status_code, 403)
|
||||
|
||||
async def test_admin_only_permission_rejects_editor(self):
|
||||
db = _RecordingDB([
|
||||
self.project,
|
||||
SimpleNamespace(role="EDITOR"),
|
||||
])
|
||||
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
await require_project_roles(
|
||||
db,
|
||||
25,
|
||||
self.current_user,
|
||||
allowed_roles=["admin"],
|
||||
)
|
||||
|
||||
self.assertEqual(context.exception.status_code, 403)
|
||||
|
|
@ -57,6 +57,27 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.mode-switch-disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mode-switch-disabled .mode-switch-option,
|
||||
.mode-switch-option:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mode-switch-disabled .mode-switch-option:hover {
|
||||
color: #4b5f7f;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.mode-switch-disabled .mode-switch-option.active,
|
||||
.mode-switch-disabled .mode-switch-option.active:hover {
|
||||
color: #1f3558;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mode-switch-small {
|
||||
padding: 2px;
|
||||
}
|
||||
|
|
@ -87,3 +108,12 @@ body.dark .mode-switch-option:hover,
|
|||
body.dark .mode-switch-option.active {
|
||||
color: #e5eeff;
|
||||
}
|
||||
|
||||
body.dark .mode-switch-disabled .mode-switch-option:hover {
|
||||
color: #9bb2d8;
|
||||
}
|
||||
|
||||
body.dark .mode-switch-disabled .mode-switch-option.active,
|
||||
body.dark .mode-switch-disabled .mode-switch-option.active:hover {
|
||||
color: #e5eeff;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ function ModeSwitch({
|
|||
options,
|
||||
ariaLabel = '模式切换',
|
||||
size = 'default',
|
||||
disabled = false,
|
||||
}) {
|
||||
const finalOptions = options || [
|
||||
{ label: viewLabel, value: 'view' },
|
||||
|
|
@ -20,7 +21,7 @@ function ModeSwitch({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`mode-switch mode-switch-${size}`}
|
||||
className={`mode-switch mode-switch-${size}${disabled ? ' mode-switch-disabled' : ''}`}
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
style={{
|
||||
|
|
@ -35,6 +36,7 @@ function ModeSwitch({
|
|||
type="button"
|
||||
className={`mode-switch-option ${value === item.value ? 'active' : ''}`}
|
||||
aria-selected={value === item.value}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange?.(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,8 @@ import { useState, useEffect, useRef, useMemo } from 'react'
|
|||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Layout, Spin, Button, Tooltip, Modal, Input, Space, Dropdown, Empty, Switch, Select, TreeSelect, Radio, Alert } from 'antd'
|
||||
import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } 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 { Viewer } from '@bytemd/react'
|
||||
import 'bytemd/dist/index.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import Highlighter from 'react-highlight-words'
|
||||
import Mark from 'mark.js'
|
||||
|
|
@ -23,6 +20,7 @@ import Toast from '@/components/Toast/Toast'
|
|||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
||||
import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree'
|
||||
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||||
import { MARKDOWN_VIEWER_PLUGINS } from '@/utils/markdownViewer'
|
||||
import './DocumentPage.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
|
@ -86,6 +84,8 @@ function DocumentPage() {
|
|||
const modeSwitchingRef = useRef(false)
|
||||
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
||||
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
|
||||
const isViewer = userRole === 'viewer'
|
||||
const canUseGit = userRole === 'owner' || userRole === 'admin'
|
||||
|
||||
const navigateWithTransition = (to) => {
|
||||
if (document.startViewTransition) {
|
||||
|
|
@ -571,21 +571,43 @@ function DocumentPage() {
|
|||
}
|
||||
}, [markdownContent, isLargeMarkdown])
|
||||
|
||||
// 搜索关键词命中后,文档加载完成滚动到第一个高亮处
|
||||
// 搜索关键词命中后,使用与引用跳转相同的 DOM 高亮方式,并滚动到首个命中处。
|
||||
useEffect(() => {
|
||||
if (loading || !searchKeyword || !markdownContent) return
|
||||
if (viewMode !== 'markdown') return
|
||||
if (isLargeMarkdown || viewMode !== 'markdown') return
|
||||
|
||||
const container = contentRef.current
|
||||
if (!container) return
|
||||
|
||||
if (loading || !searchKeyword || !markdownContent) {
|
||||
try {
|
||||
new Mark(container).unmark({ className: 'search-highlight' })
|
||||
} catch (error) {
|
||||
console.warn('清理搜索高亮失败:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let canceled = false
|
||||
const timer = window.setTimeout(() => {
|
||||
if (canceled) return
|
||||
const container = contentRef.current
|
||||
const target = container?.querySelector('.search-highlight')
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
// 临时强调被引用的位置,短暂后淡出
|
||||
target.classList.add('cited-highlight-flash')
|
||||
window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400)
|
||||
try {
|
||||
const instance = new Mark(container)
|
||||
instance.unmark({ className: 'search-highlight' })
|
||||
instance.mark(searchKeyword, {
|
||||
className: 'search-highlight',
|
||||
separateWordSearch: false,
|
||||
acrossElements: true,
|
||||
done: () => {
|
||||
const target = container.querySelector('.search-highlight')
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
target.classList.add('cited-highlight-flash')
|
||||
window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('搜索高亮失败:', error)
|
||||
}
|
||||
}, 260)
|
||||
|
||||
|
|
@ -593,7 +615,7 @@ function DocumentPage() {
|
|||
canceled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [loading, markdownContent, searchKeyword, viewMode])
|
||||
}, [loading, markdownContent, searchKeyword, viewMode, isLargeMarkdown])
|
||||
|
||||
// 知识库引用跳转(URL 带 hl):用 mark.js 跨文本节点高亮并滚动定位。
|
||||
// 引文含 markdown 语法时,渲染后的 DOM 文本与原文不一致,且关键词可能
|
||||
|
|
@ -800,6 +822,23 @@ function DocumentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleMarkdownContentClick = (event) => {
|
||||
const target = event.target
|
||||
const anchor = target instanceof Element ? target.closest('a') : null
|
||||
if (!anchor) return
|
||||
|
||||
const href = anchor.getAttribute('href')
|
||||
if (!href) return
|
||||
|
||||
if (isExternalHref(href)) {
|
||||
event.preventDefault()
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
handleMarkdownLink(event, href)
|
||||
}
|
||||
|
||||
const handleGitPull = async (repoId = null, force = false, syncScope = null, subPath = '') => {
|
||||
if (gitRepos.length === 0) {
|
||||
Toast.warning('未配置Git仓库')
|
||||
|
|
@ -953,13 +992,14 @@ function DocumentPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const renderGitActions = () => {
|
||||
const renderGitActions = (disabled = false) => {
|
||||
const pullButton = (
|
||||
<Tooltip title="Git Pull(可选择同步目录)">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => openGitSyncModal('pull')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
|
|
@ -970,6 +1010,7 @@ function DocumentPage() {
|
|||
size="middle"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => openGitSyncModal('push')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
|
|
@ -999,10 +1040,10 @@ function DocumentPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items: pullItems }}>
|
||||
<Dropdown menu={{ items: pullItems }} disabled={disabled}>
|
||||
{pullButton}
|
||||
</Dropdown>
|
||||
<Dropdown menu={{ items: pushItems }}>
|
||||
<Dropdown menu={{ items: pushItems }} disabled={disabled}>
|
||||
{pushButton}
|
||||
</Dropdown>
|
||||
</>
|
||||
|
|
@ -1274,31 +1315,29 @@ function DocumentPage() {
|
|||
</div>
|
||||
<div className="docs-sider-actions">
|
||||
<div className="mode-actions-row">
|
||||
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
||||
{userRole !== 'viewer' ? (
|
||||
<ModeSwitch
|
||||
size="small"
|
||||
value={modeSwitchValue}
|
||||
onChange={(mode) => {
|
||||
if (mode === 'edit' && !modeSwitchingRef.current) {
|
||||
modeSwitchingRef.current = true
|
||||
setModeSwitchValue('edit')
|
||||
setTimeout(() => {
|
||||
handleEdit()
|
||||
}, 160)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{/* 查看者保留入口但禁用,便于明确当前权限 */}
|
||||
<ModeSwitch
|
||||
size="small"
|
||||
value={modeSwitchValue}
|
||||
disabled={isViewer}
|
||||
onChange={(mode) => {
|
||||
if (mode === 'edit' && !modeSwitchingRef.current) {
|
||||
modeSwitchingRef.current = true
|
||||
setModeSwitchValue('edit')
|
||||
setTimeout(() => {
|
||||
handleEdit()
|
||||
}, 160)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Space.Compact className="mode-actions-group">
|
||||
{userRole !== 'viewer' && renderGitActions()}
|
||||
{renderGitActions(!canUseGit)}
|
||||
<Tooltip title="分享">
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={handleShare}
|
||||
disabled={isViewer}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新">
|
||||
|
|
@ -1446,14 +1485,11 @@ function DocumentPage() {
|
|||
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
<div className="bytemd-viewer-wrapper" onClick={handleMarkdownContentClick}>
|
||||
<Viewer
|
||||
value={markdownContent}
|
||||
plugins={MARKDOWN_VIEWER_PLUGINS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@ import { useState, useEffect, useRef } from 'react'
|
|||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Layout, Modal, Input, Spin, Button, Space, Tooltip } from 'antd'
|
||||
import { LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import { Viewer } from '@bytemd/react'
|
||||
import 'bytemd/dist/index.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import Toast from '@/components/Toast/Toast'
|
||||
import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
|
||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||||
import { MARKDOWN_VIEWER_PLUGINS } from '@/utils/markdownViewer'
|
||||
import {
|
||||
getFileSharePublicInfo,
|
||||
verifyFileSharePassword,
|
||||
|
|
@ -214,6 +213,23 @@ function FileSharePage() {
|
|||
Toast.error('无法打开内部文件链接', '单文件分享模式不支持跳转到其他内部文件')
|
||||
}
|
||||
|
||||
const handleMarkdownContentClick = (event) => {
|
||||
const target = event.target
|
||||
const anchor = target instanceof Element ? target.closest('a') : null
|
||||
if (!anchor) return
|
||||
|
||||
const href = anchor.getAttribute('href')
|
||||
if (!href) return
|
||||
|
||||
if (isExternalHref(href)) {
|
||||
event.preventDefault()
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
handleMarkdownLink(event, href)
|
||||
}
|
||||
|
||||
const markdownComponents = {
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
const isExternal = isExternalHref(href)
|
||||
|
|
@ -332,14 +348,11 @@ function FileSharePage() {
|
|||
components={markdownComponents}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug, rehypeHighlight]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
<div className="bytemd-viewer-wrapper" onClick={handleMarkdownContentClick}>
|
||||
<Viewer
|
||||
value={markdownContent}
|
||||
plugins={MARKDOWN_VIEWER_PLUGINS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ import { useState, useEffect, useRef, useMemo } from 'react'
|
|||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { Layout, Spin, Button, Modal, Input, Drawer, Empty, Tooltip, Space } from 'antd'
|
||||
import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, UnorderedListOutlined, CloseOutlined } from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import { Viewer } from '@bytemd/react'
|
||||
import 'bytemd/dist/index.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import Mark from 'mark.js'
|
||||
import Highlighter from 'react-highlight-words'
|
||||
|
|
@ -15,6 +13,7 @@ import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
|
|||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||
import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree'
|
||||
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||||
import { MARKDOWN_VIEWER_PLUGINS } from '@/utils/markdownViewer'
|
||||
import {
|
||||
getProjectSharePublicInfo,
|
||||
getProjectShareTree,
|
||||
|
|
@ -607,6 +606,24 @@ function ProjectSharePage() {
|
|||
openSharedFile(resolveMarkdownTarget(pathOnly))
|
||||
}
|
||||
|
||||
const handleMarkdownContentClick = (event) => {
|
||||
if (event.defaultPrevented) return
|
||||
const target = event.target
|
||||
const anchor = target instanceof Element ? target.closest('a') : null
|
||||
if (!anchor) return
|
||||
|
||||
const href = anchor.getAttribute('href')
|
||||
if (!href) return
|
||||
|
||||
if (isExternalHref(href)) {
|
||||
event.preventDefault()
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
handleMarkdownLink(event, href)
|
||||
}
|
||||
|
||||
const handleExportPDF = () => {
|
||||
if (!selectedFile) return
|
||||
if (viewMode === 'pdf') {
|
||||
|
|
@ -893,21 +910,11 @@ function ProjectSharePage() {
|
|||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-body" onClick={(e) => {
|
||||
if (e.defaultPrevented) return
|
||||
const target = e.target.closest('a')
|
||||
if (target) {
|
||||
const href = target.getAttribute('href')
|
||||
if (href) handleMarkdownLink(e, href)
|
||||
}
|
||||
}} ref={viewerRef}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug, rehypeHighlight]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
<div className="bytemd-viewer-wrapper" onClick={handleMarkdownContentClick} ref={viewerRef}>
|
||||
<Viewer
|
||||
value={markdownContent}
|
||||
plugins={MARKDOWN_VIEWER_PLUGINS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@
|
|||
flex: none;
|
||||
}
|
||||
|
||||
.project-card .ant-card-actions > li:not(:last-child) {
|
||||
border-inline-end-color: rgba(15, 23, 42, 0.18) !important;
|
||||
}
|
||||
|
||||
body.dark .project-card .ant-card-actions > li:not(:last-child) {
|
||||
border-inline-end-color: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.project-card-public-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
|
@ -332,6 +340,19 @@ body.dark .project-card-role-badge.role-editor {
|
|||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.project-card-member-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.project-card-member-action:hover {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
/* ========== 搜索结果卡片(保持旧版居中风格) ========== */
|
||||
.project-card-icon {
|
||||
margin-bottom: 16px;
|
||||
|
|
|
|||
|
|
@ -789,6 +789,17 @@ function ProjectList({ type = 'my' }) {
|
|||
{cardRoleLabel(project)}
|
||||
</span>
|
||||
</Tooltip>,
|
||||
...(normalizeRole(project.user_role) === 'admin' ? [
|
||||
<Tooltip key="members" title="成员管理">
|
||||
<span
|
||||
className="project-card-member-action"
|
||||
onClick={(e) => handleMembers(e, project)}
|
||||
>
|
||||
<TeamOutlined />
|
||||
<span>成员管理</span>
|
||||
</span>
|
||||
</Tooltip>,
|
||||
] : []),
|
||||
]}
|
||||
>
|
||||
{/* 公开项目标识(仅我的项目显示,参与项目不显示) */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import gfm from '@bytemd/plugin-gfm'
|
||||
import highlight from '@bytemd/plugin-highlight'
|
||||
import breaks from '@bytemd/plugin-breaks'
|
||||
import frontmatter from '@bytemd/plugin-frontmatter'
|
||||
import gemoji from '@bytemd/plugin-gemoji'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
|
||||
export const MARKDOWN_VIEWER_PLUGINS = [
|
||||
gfm(),
|
||||
highlight(),
|
||||
breaks(),
|
||||
frontmatter(),
|
||||
gemoji(),
|
||||
{ rehype: (processor) => processor.use(rehypeSlug) },
|
||||
]
|
||||
Loading…
Reference in New Issue