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