fix project role permission

main
mula.liu 2026-09-09 18:20:13 +08:00
parent 5d41339c4a
commit ba80d280d1
11 changed files with 361 additions and 191 deletions

View File

@ -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 Pullsub_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 Pushsub_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:

View File

@ -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)

View File

@ -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)

View File

@ -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;
}

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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>,
] : []),
]}
>
{/* 公开项目标识(仅我的项目显示,参与项目不显示) */}

View File

@ -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) },
]