diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 570cbad..15ff222 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -668,6 +668,70 @@ async def add_project_member( return success_response(data=member_data.dict(), message="成员添加成功") +@router.put("/{project_id}/members/{user_id}", response_model=dict) +async def update_project_member_role( + project_id: int, + user_id: int, + member_in: ProjectMemberUpdate, + request: Request, + current_user: User = Depends(get_current_user), + 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="无权修改成员角色") + + # 不能修改项目所有者的角色 + if project.owner_id == user_id: + raise HTTPException(status_code=400, detail="不能修改项目所有者的角色") + + # 查询目标成员 + target_result = await db.execute( + select(ProjectMember).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == user_id + ) + ) + target_member = target_result.scalar_one_or_none() + if not target_member: + raise HTTPException(status_code=404, detail="该用户不是项目成员") + + old_role = target_member.role + target_member.role = member_in.role + await db.commit() + await db.refresh(target_member) + + # 记录操作日志 + await log_service.log_member_operation( + db=db, + operation_type=OperationType.UPDATE_MEMBER_ROLE, + project_id=project_id, + target_user_id=user_id, + user=current_user, + detail={"old_role": old_role, "new_role": member_in.role}, + request=request, + ) + + member_data = ProjectMemberResponse.from_orm(target_member) + return success_response(data=member_data.dict(), message="成员角色已更新") + + @router.delete("/{project_id}/members/{user_id}", response_model=dict) async def remove_project_member( project_id: int, diff --git a/backend/app/core/enums.py b/backend/app/core/enums.py index 37dfcd6..ac84702 100644 --- a/backend/app/core/enums.py +++ b/backend/app/core/enums.py @@ -15,6 +15,7 @@ class OperationType(str, Enum): # 成员操作 ADD_MEMBER = "add_member" REMOVE_MEMBER = "remove_member" + UPDATE_MEMBER_ROLE = "update_member_role" # 文件操作 CREATE_FILE = "create_file" diff --git a/frontend/src/api/project.js b/frontend/src/api/project.js index d85fb1b..9e63a4a 100644 --- a/frontend/src/api/project.js +++ b/frontend/src/api/project.js @@ -149,6 +149,17 @@ export function removeProjectMember(projectId, userId) { }) } +/** + * 修改项目成员角色 + */ +export function updateProjectMemberRole(projectId, userId, role) { + return request({ + url: `/projects/${projectId}/members/${userId}`, + method: 'put', + data: { role }, + }) +} + /** * Git Pull */ diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx index 78b1253..7478f47 100644 --- a/frontend/src/pages/Document/DocumentEditor.jsx +++ b/frontend/src/pages/Document/DocumentEditor.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useNavigate, useSearchParams } from 'react-router-dom' -import { Layout, Button, Modal, Input, Space, Tooltip, Upload, Select, Progress, TreeSelect, Empty } from 'antd' +import { Layout, Button, Modal, Input, Space, Tooltip, Upload, Select, Progress, TreeSelect, Empty, Tabs } from 'antd' import { FolderOutlined, FolderOpenOutlined, @@ -24,6 +24,7 @@ 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 GithubSlugger from 'github-slugger' import 'bytemd/dist/index.css' import 'highlight.js/styles/github.css' import { @@ -79,6 +80,8 @@ function DocumentEditor() { const [selectedFolderKey, setSelectedFolderKey] = useState(null) // 当前选中的文件夹(null=未选择文件夹,ROOT_FOLDER_KEY=根目录) const [linkModalVisible, setLinkModalVisible] = useState(false) const [linkTarget, setLinkTarget] = useState(null) + const [linkTab, setLinkTab] = useState('page') + const [linkHeading, setLinkHeading] = useState(null) const [projectName, setProjectName] = useState('') // 项目名称 const [userRole, setUserRole] = useState('viewer') const [modeSwitchValue, setModeSwitchValue] = useState('edit') @@ -104,6 +107,22 @@ function DocumentEditor() { return markSelectable(treeData) }, [treeData]) + // 当前文档标题列表(页内链接用) + const pageHeadings = useMemo(() => { + const slugger = new GithubSlugger() + const headings = [] + if (fileContent) { + fileContent.split('\n').forEach((line) => { + const match = line.match(/^(#{1,6})\s+(.+)$/) + if (!match) return + const level = match[1].length + const title = match[2].trim() + headings.push({ title, level, slug: slugger.slug(title) }) + }) + } + return headings + }, [fileContent]) + const navigateWithTransition = (to) => { if (document.startViewTransition) { document.startViewTransition(() => navigate(to)) @@ -391,32 +410,40 @@ function DocumentEditor() { .join('/') } - // 插入内链接 + // 插入内链接(页内锚点 / 页间文件) const handleInsertLink = () => { - if (!linkTarget) { - Toast.warning('提示', '请选择文件') - return - } - - if (editorCtxRef.current && editorCtxRef.current.editor) { - const editor = editorCtxRef.current.editor - // 获取当前选中的文字 - const selection = editor.getSelection() - + const editor = editorCtxRef.current && editorCtxRef.current.editor + if (!editor) return + + let linkText = '' + if (linkTab === 'page') { + if (!linkHeading) { + Toast.warning('提示', '请选择页内标题') + return + } + const heading = pageHeadings.find((h) => h.slug === linkHeading) + if (!heading) return + const linkTitle = editor.getSelection() || heading.title + linkText = `[${linkTitle}](#${heading.slug})` + } else { + if (!linkTarget) { + Toast.warning('提示', '请选择文件') + return + } // 简单的从路径获取文件名作为备选 const fileName = linkTarget.split('/').pop() - // 如果没有选中文字,则使用文件名作为链接文字;否则保留原文字 - const linkTitle = selection || fileName - const linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})` - - editor.replaceSelection(linkText) - editor.focus() + const linkTitle = editor.getSelection() || fileName + linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})` } - + + editor.replaceSelection(linkText) + editor.focus() setLinkModalVisible(false) setLinkTarget(null) + setLinkHeading(null) } + useEffect(() => { fetchTree() }, [projectId]) @@ -985,6 +1012,7 @@ function DocumentEditor() { type: 'action', click: (ctx) => { editorCtxRef.current = ctx + setLinkHeading(null) setLinkModalVisible(true) }, }, @@ -1443,26 +1471,62 @@ function DocumentEditor() { onCancel={() => { setLinkModalVisible(false) setLinkTarget(null) + setLinkHeading(null) }} > -
选择要链接的文件:
-选择页内标题(插入锚点链接):
+