优化了部分UI
parent
dfd46184b2
commit
b3f5fb00b7
|
|
@ -668,6 +668,70 @@ async def add_project_member(
|
||||||
return success_response(data=member_data.dict(), message="成员添加成功")
|
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)
|
@router.delete("/{project_id}/members/{user_id}", response_model=dict)
|
||||||
async def remove_project_member(
|
async def remove_project_member(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class OperationType(str, Enum):
|
||||||
# 成员操作
|
# 成员操作
|
||||||
ADD_MEMBER = "add_member"
|
ADD_MEMBER = "add_member"
|
||||||
REMOVE_MEMBER = "remove_member"
|
REMOVE_MEMBER = "remove_member"
|
||||||
|
UPDATE_MEMBER_ROLE = "update_member_role"
|
||||||
|
|
||||||
# 文件操作
|
# 文件操作
|
||||||
CREATE_FILE = "create_file"
|
CREATE_FILE = "create_file"
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Git Pull
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
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, 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 {
|
import {
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
FolderOpenOutlined,
|
FolderOpenOutlined,
|
||||||
|
|
@ -24,6 +24,7 @@ import highlight from '@bytemd/plugin-highlight'
|
||||||
import breaks from '@bytemd/plugin-breaks'
|
import breaks from '@bytemd/plugin-breaks'
|
||||||
import frontmatter from '@bytemd/plugin-frontmatter'
|
import frontmatter from '@bytemd/plugin-frontmatter'
|
||||||
import gemoji from '@bytemd/plugin-gemoji'
|
import gemoji from '@bytemd/plugin-gemoji'
|
||||||
|
import GithubSlugger from 'github-slugger'
|
||||||
import 'bytemd/dist/index.css'
|
import 'bytemd/dist/index.css'
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import {
|
import {
|
||||||
|
|
@ -79,6 +80,8 @@ function DocumentEditor() {
|
||||||
const [selectedFolderKey, setSelectedFolderKey] = useState(null) // 当前选中的文件夹(null=未选择文件夹,ROOT_FOLDER_KEY=根目录)
|
const [selectedFolderKey, setSelectedFolderKey] = useState(null) // 当前选中的文件夹(null=未选择文件夹,ROOT_FOLDER_KEY=根目录)
|
||||||
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
||||||
const [linkTarget, setLinkTarget] = useState(null)
|
const [linkTarget, setLinkTarget] = useState(null)
|
||||||
|
const [linkTab, setLinkTab] = useState('page')
|
||||||
|
const [linkHeading, setLinkHeading] = useState(null)
|
||||||
const [projectName, setProjectName] = useState('') // 项目名称
|
const [projectName, setProjectName] = useState('') // 项目名称
|
||||||
const [userRole, setUserRole] = useState('viewer')
|
const [userRole, setUserRole] = useState('viewer')
|
||||||
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
|
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
|
||||||
|
|
@ -104,6 +107,22 @@ function DocumentEditor() {
|
||||||
return markSelectable(treeData)
|
return markSelectable(treeData)
|
||||||
}, [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) => {
|
const navigateWithTransition = (to) => {
|
||||||
if (document.startViewTransition) {
|
if (document.startViewTransition) {
|
||||||
document.startViewTransition(() => navigate(to))
|
document.startViewTransition(() => navigate(to))
|
||||||
|
|
@ -391,32 +410,40 @@ function DocumentEditor() {
|
||||||
.join('/')
|
.join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 插入内链接
|
// 插入内链接(页内锚点 / 页间文件)
|
||||||
const handleInsertLink = () => {
|
const handleInsertLink = () => {
|
||||||
|
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) {
|
if (!linkTarget) {
|
||||||
Toast.warning('提示', '请选择文件')
|
Toast.warning('提示', '请选择文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editorCtxRef.current && editorCtxRef.current.editor) {
|
|
||||||
const editor = editorCtxRef.current.editor
|
|
||||||
// 获取当前选中的文字
|
|
||||||
const selection = editor.getSelection()
|
|
||||||
|
|
||||||
// 简单的从路径获取文件名作为备选
|
// 简单的从路径获取文件名作为备选
|
||||||
const fileName = linkTarget.split('/').pop()
|
const fileName = linkTarget.split('/').pop()
|
||||||
// 如果没有选中文字,则使用文件名作为链接文字;否则保留原文字
|
const linkTitle = editor.getSelection() || fileName
|
||||||
const linkTitle = selection || fileName
|
linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})`
|
||||||
const linkText = `[${linkTitle}](${encodeMarkdownLinkTarget(linkTarget)})`
|
}
|
||||||
|
|
||||||
editor.replaceSelection(linkText)
|
editor.replaceSelection(linkText)
|
||||||
editor.focus()
|
editor.focus()
|
||||||
}
|
|
||||||
|
|
||||||
setLinkModalVisible(false)
|
setLinkModalVisible(false)
|
||||||
setLinkTarget(null)
|
setLinkTarget(null)
|
||||||
|
setLinkHeading(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTree()
|
fetchTree()
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
|
|
@ -985,6 +1012,7 @@ function DocumentEditor() {
|
||||||
type: 'action',
|
type: 'action',
|
||||||
click: (ctx) => {
|
click: (ctx) => {
|
||||||
editorCtxRef.current = ctx
|
editorCtxRef.current = ctx
|
||||||
|
setLinkHeading(null)
|
||||||
setLinkModalVisible(true)
|
setLinkModalVisible(true)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1443,8 +1471,40 @@ function DocumentEditor() {
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setLinkModalVisible(false)
|
setLinkModalVisible(false)
|
||||||
setLinkTarget(null)
|
setLinkTarget(null)
|
||||||
|
setLinkHeading(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={linkTab}
|
||||||
|
onChange={setLinkTab}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'page',
|
||||||
|
label: '页内链接',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<p>选择页内标题(插入锚点链接):</p>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="请选择页内标题"
|
||||||
|
value={linkHeading}
|
||||||
|
onChange={setLinkHeading}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
notFoundContent="当前页面暂无标题"
|
||||||
|
allowClear
|
||||||
|
options={pageHeadings.map((h) => ({
|
||||||
|
label: `${'#'.repeat(h.level)} ${h.title}`,
|
||||||
|
value: h.slug,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'file',
|
||||||
|
label: '页间链接',
|
||||||
|
children: (
|
||||||
<div>
|
<div>
|
||||||
<p>选择要链接的文件:</p>
|
<p>选择要链接的文件:</p>
|
||||||
<TreeSelect
|
<TreeSelect
|
||||||
|
|
@ -1463,6 +1523,10 @@ function DocumentEditor() {
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
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, ArrowLeftOutlined } from '@ant-design/icons'
|
import { LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeHighlight from 'rehype-highlight'
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
|
|
@ -22,11 +22,21 @@ import './PreviewPage.css'
|
||||||
|
|
||||||
const { Content } = Layout
|
const { Content } = Layout
|
||||||
|
|
||||||
|
// 查找内容区实际可滚动的容器(普通 MD:主内容区;PDF/大文档:内部滚动容器)
|
||||||
|
const findScrollableEl = (root) => {
|
||||||
|
if (!root) return null
|
||||||
|
if (root.scrollHeight > root.clientHeight + 1) return root
|
||||||
|
const inner = root.querySelector('.pdf-content, .markdown-virtual-list')
|
||||||
|
if (inner && inner.scrollHeight > inner.clientHeight + 1) return inner
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function FileSharePage() {
|
function FileSharePage() {
|
||||||
const { shareCode } = useParams()
|
const { shareCode } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const contentRef = useRef(null)
|
const contentRef = useRef(null)
|
||||||
const largeMarkdownRef = useRef(null)
|
const largeMarkdownRef = useRef(null)
|
||||||
|
const [headerVisible, setHeaderVisible] = useState(false)
|
||||||
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
||||||
const [shareInfo, setShareInfo] = useState(null)
|
const [shareInfo, setShareInfo] = useState(null)
|
||||||
const [contentInfo, setContentInfo] = useState(null)
|
const [contentInfo, setContentInfo] = useState(null)
|
||||||
|
|
@ -50,6 +60,42 @@ function FileSharePage() {
|
||||||
return () => window.removeEventListener('resize', checkMobile)
|
return () => window.removeEventListener('resize', checkMobile)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 内容区 Header:仅在滚动时显示,滚动停止后延迟隐藏(含 PDF/大文档内部滚动)
|
||||||
|
useEffect(() => {
|
||||||
|
const root = contentRef.current
|
||||||
|
if (!root) return
|
||||||
|
const hideTimer = { current: null }
|
||||||
|
const clearHideTimer = () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current)
|
||||||
|
hideTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sync = (scrollEl = null) => {
|
||||||
|
const target = scrollEl || findScrollableEl(root)
|
||||||
|
if (!target) {
|
||||||
|
clearHideTimer()
|
||||||
|
setHeaderVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target.scrollTop > 8) {
|
||||||
|
setHeaderVisible(true)
|
||||||
|
clearHideTimer()
|
||||||
|
hideTimer.current = setTimeout(() => setHeaderVisible(false), 1200)
|
||||||
|
} else {
|
||||||
|
clearHideTimer()
|
||||||
|
setHeaderVisible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onScroll = (e) => sync(e.target)
|
||||||
|
root.addEventListener('scroll', onScroll, true)
|
||||||
|
sync()
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener('scroll', onScroll, true)
|
||||||
|
clearHideTimer()
|
||||||
|
}
|
||||||
|
}, [contentInfo?.type, isLargeMarkdown, loading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!markdownContent || isLargeMarkdown) {
|
if (!markdownContent || isLargeMarkdown) {
|
||||||
setTocItems([])
|
setTocItems([])
|
||||||
|
|
@ -176,14 +222,14 @@ function FileSharePage() {
|
||||||
<div className="file-share-shell">
|
<div className="file-share-shell">
|
||||||
<Layout className="file-share-content-layout">
|
<Layout className="file-share-content-layout">
|
||||||
<Content className="file-share-content" ref={contentRef}>
|
<Content className="file-share-content" ref={contentRef}>
|
||||||
<div className="preview-content-header file-share-content-header">
|
<div className={`preview-content-header file-share-content-header${headerVisible ? '' : ' preview-header-hidden'}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="project-back-button"
|
className="project-back-button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
aria-label="返回"
|
aria-label="关闭"
|
||||||
>
|
>
|
||||||
<ArrowLeftOutlined />
|
<CloseOutlined />
|
||||||
</button>
|
</button>
|
||||||
<h3 className="preview-header-title">
|
<h3 className="preview-header-title">
|
||||||
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
|
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,14 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区 Header:顶部隐藏、滚动时显示 */
|
||||||
|
.preview-content-header.preview-header-hidden {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-header-leading-actions {
|
.preview-header-leading-actions {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
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, ArrowLeftOutlined } 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 ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeHighlight from 'rehype-highlight'
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
|
|
@ -29,6 +29,15 @@ import './PreviewPage.css'
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
const ROOT_FOLDER_KEY = '__root__'
|
const ROOT_FOLDER_KEY = '__root__'
|
||||||
|
|
||||||
|
// 查找内容区实际可滚动的容器(普通 MD:主内容区;PDF/大文档:内部滚动容器)
|
||||||
|
const findScrollableEl = (root) => {
|
||||||
|
if (!root) return null
|
||||||
|
if (root.scrollHeight > root.clientHeight + 1) return root
|
||||||
|
const inner = root.querySelector('.pdf-content, .markdown-virtual-list')
|
||||||
|
if (inner && inner.scrollHeight > inner.clientHeight + 1) return inner
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const HighlightText = ({ text, keyword }) => {
|
const HighlightText = ({ text, keyword }) => {
|
||||||
if (!keyword || !text) return text
|
if (!keyword || !text) return text
|
||||||
return (
|
return (
|
||||||
|
|
@ -67,6 +76,7 @@ function ProjectSharePage() {
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const contentRef = useRef(null)
|
const contentRef = useRef(null)
|
||||||
const viewerRef = useRef(null)
|
const viewerRef = useRef(null)
|
||||||
|
const [headerVisible, setHeaderVisible] = useState(false)
|
||||||
const largeMarkdownRef = useRef(null)
|
const largeMarkdownRef = useRef(null)
|
||||||
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
||||||
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
|
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
|
||||||
|
|
@ -86,6 +96,42 @@ function ProjectSharePage() {
|
||||||
return () => window.removeEventListener('resize', checkMobile)
|
return () => window.removeEventListener('resize', checkMobile)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 内容区 Header:仅在滚动时显示,滚动停止后延迟隐藏(含 PDF/大文档内部滚动)
|
||||||
|
useEffect(() => {
|
||||||
|
const root = contentRef.current
|
||||||
|
if (!root) return
|
||||||
|
const hideTimer = { current: null }
|
||||||
|
const clearHideTimer = () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current)
|
||||||
|
hideTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sync = (scrollEl = null) => {
|
||||||
|
const target = scrollEl || findScrollableEl(root)
|
||||||
|
if (!target) {
|
||||||
|
clearHideTimer()
|
||||||
|
setHeaderVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target.scrollTop > 8) {
|
||||||
|
setHeaderVisible(true)
|
||||||
|
clearHideTimer()
|
||||||
|
hideTimer.current = setTimeout(() => setHeaderVisible(false), 1200)
|
||||||
|
} else {
|
||||||
|
clearHideTimer()
|
||||||
|
setHeaderVisible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onScroll = (e) => sync(e.target)
|
||||||
|
root.addEventListener('scroll', onScroll, true)
|
||||||
|
sync()
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener('scroll', onScroll, true)
|
||||||
|
clearHideTimer()
|
||||||
|
}
|
||||||
|
}, [viewMode, isLargeMarkdown, loading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewerRef.current && viewMode === 'markdown' && !isLargeMarkdown) {
|
if (viewerRef.current && viewMode === 'markdown' && !isLargeMarkdown) {
|
||||||
const instance = new Mark(viewerRef.current)
|
const instance = new Mark(viewerRef.current)
|
||||||
|
|
@ -633,9 +679,9 @@ function ProjectSharePage() {
|
||||||
type="button"
|
type="button"
|
||||||
className="project-back-button"
|
className="project-back-button"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
aria-label="返回"
|
aria-label="关闭"
|
||||||
>
|
>
|
||||||
<ArrowLeftOutlined />
|
<CloseOutlined />
|
||||||
</button>
|
</button>
|
||||||
<h2>{projectInfo?.name || '项目分享'}</h2>
|
<h2>{projectInfo?.name || '项目分享'}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -665,16 +711,16 @@ function ProjectSharePage() {
|
||||||
|
|
||||||
<Layout className="preview-content-layout">
|
<Layout className="preview-content-layout">
|
||||||
<Content className="preview-content" ref={contentRef}>
|
<Content className="preview-content" ref={contentRef}>
|
||||||
<div className="preview-content-header">
|
<div className={`preview-content-header${headerVisible ? '' : ' preview-header-hidden'}`}>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="preview-header-leading-actions">
|
<div className="preview-header-leading-actions">
|
||||||
<Tooltip title="返回">
|
<Tooltip title="关闭">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<CloseOutlined />}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="返回"
|
aria-label="关闭"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="目录索引">
|
<Tooltip title="目录索引">
|
||||||
|
|
|
||||||
|
|
@ -311,11 +311,25 @@ body.dark .project-card-role-badge.role-editor {
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card-share .ant-card-actions {
|
.project-card-share .ant-card-actions {
|
||||||
background: rgba(82, 196, 26, 0.05);
|
background: var(--bg-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card-share .ant-card-actions li span {
|
/* 参与项目底部功能项:与我的项目操作项风格保持一致(普通操作项,无药丸背景) */
|
||||||
color: #52c41a;
|
.project-card-view-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
min-width: 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-view-action:hover {
|
||||||
|
color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 搜索结果卡片(保持旧版居中风格) ========== */
|
/* ========== 搜索结果卡片(保持旧版居中风格) ========== */
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd'
|
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd'
|
||||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, BranchesOutlined, GithubOutlined, GithubFilled, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons'
|
import { PlusOutlined, FolderOutlined, TeamOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, BranchesOutlined, GithubOutlined, GithubFilled, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, SafetyOutlined, FormOutlined, ReadOutlined } from '@ant-design/icons'
|
||||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, testGitRepoConnection, transferProject } from '@/api/project'
|
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, updateProjectMemberRole, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, testGitRepoConnection, transferProject } from '@/api/project'
|
||||||
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||||||
import { getUserList } from '@/api/users'
|
import { getUserList } from '@/api/users'
|
||||||
import { searchDocuments } from '@/api/search'
|
import { searchDocuments } from '@/api/search'
|
||||||
|
|
@ -50,6 +50,9 @@ function ProjectList({ type = 'my' }) {
|
||||||
return roleMap[role] || role || '查看者'
|
return roleMap[role] || role || '查看者'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成员管理弹窗角色显示名
|
||||||
|
const memberRoleLabels = { admin: '管理员', editor: '编辑者', viewer: '查看者' }
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
|
|
@ -78,6 +81,13 @@ function ProjectList({ type = 'my' }) {
|
||||||
return formatDate(dateStr)
|
return formatDate(dateStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按项目更新时间倒序排序(刚更新的项目排在前面;无更新时间时按创建时间)
|
||||||
|
const sortByLastActivity = (list) => [...list].sort((a, b) => {
|
||||||
|
const ta = new Date(a.last_activity_at || a.created_at || 0).getTime()
|
||||||
|
const tb = new Date(b.last_activity_at || b.created_at || 0).getTime()
|
||||||
|
return (Number.isFinite(tb) ? tb : 0) - (Number.isFinite(ta) ? ta : 0)
|
||||||
|
})
|
||||||
|
|
||||||
// 卡片归属用户显示名(我的项目 -> 当前用户;参与项目 -> 项目所有者)
|
// 卡片归属用户显示名(我的项目 -> 当前用户;参与项目 -> 项目所有者)
|
||||||
const cardOwnerName = (project) => {
|
const cardOwnerName = (project) => {
|
||||||
if (type === 'my') {
|
if (type === 'my') {
|
||||||
|
|
@ -107,9 +117,9 @@ function ProjectList({ type = 'my' }) {
|
||||||
|
|
||||||
// 卡片角色徽章图标
|
// 卡片角色徽章图标
|
||||||
const cardRoleIcon = (project) => {
|
const cardRoleIcon = (project) => {
|
||||||
if (type === 'my' || project.user_role === 'admin') return <CrownOutlined />
|
if (type === 'my' || project.user_role === 'admin') return <SafetyOutlined />
|
||||||
if (project.user_role === 'editor') return <EditOutlined />
|
if (project.user_role === 'editor') return <FormOutlined />
|
||||||
return <EyeOutlined />
|
return <ReadOutlined />
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -184,7 +194,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
} else {
|
} else {
|
||||||
res = await getMyProjects()
|
res = await getMyProjects()
|
||||||
}
|
}
|
||||||
setProjects(res.data || [])
|
setProjects(sortByLastActivity(res.data || []))
|
||||||
// 参与项目:加载每个项目的未读更新通知数(与消息通知保持一致)
|
// 参与项目:加载每个项目的未读更新通知数(与消息通知保持一致)
|
||||||
if (type === 'share') {
|
if (type === 'share') {
|
||||||
getUnreadByProject()
|
getUnreadByProject()
|
||||||
|
|
@ -599,6 +609,21 @@ function ProjectList({ type = 'my' }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修改成员角色(列表内直接切换)
|
||||||
|
const handleChangeMemberRole = async (record, role) => {
|
||||||
|
try {
|
||||||
|
await updateProjectMemberRole(currentProject.id, record.user_id, role)
|
||||||
|
Toast.success('成功', `${record.nickname || record.username} 的角色已更新为${memberRoleLabels[role] || role}`)
|
||||||
|
// 刷新成员列表
|
||||||
|
const res = await getProjectMembers(currentProject.id)
|
||||||
|
setMembers(res.data || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update member role error:', error)
|
||||||
|
const errorMsg = error.response?.data?.detail || error.message || '更新角色失败'
|
||||||
|
Toast.error(errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理搜索输入变化
|
// 处理搜索输入变化
|
||||||
const handleSearchChange = (value) => {
|
const handleSearchChange = (value) => {
|
||||||
setSearchKeyword(value)
|
setSearchKeyword(value)
|
||||||
|
|
@ -754,7 +779,12 @@ function ProjectList({ type = 'my' }) {
|
||||||
<Tooltip key="kb" title="知识库向量化"><DatabaseOutlined onClick={(e) => handleKnowledge(e, project)} /></Tooltip>,
|
<Tooltip key="kb" title="知识库向量化"><DatabaseOutlined onClick={(e) => handleKnowledge(e, project)} /></Tooltip>,
|
||||||
<Tooltip key="members" title="成员管理"><TeamOutlined onClick={(e) => handleMembers(e, project)} /></Tooltip>,
|
<Tooltip key="members" title="成员管理"><TeamOutlined onClick={(e) => handleMembers(e, project)} /></Tooltip>,
|
||||||
] : [
|
] : [
|
||||||
<Tooltip key="view" title="进入项目"><EyeOutlined /></Tooltip>,
|
<Tooltip key="view" title={`以${cardRoleLabel(project)}身份进入项目`}>
|
||||||
|
<span className={`project-card-view-action ${cardRoleClass(project)}`}>
|
||||||
|
{cardRoleIcon(project)}
|
||||||
|
{cardRoleLabel(project)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* 公开项目标识(仅我的项目显示,参与项目不显示) */}
|
{/* 公开项目标识(仅我的项目显示,参与项目不显示) */}
|
||||||
|
|
@ -799,14 +829,13 @@ function ProjectList({ type = 'my' }) {
|
||||||
<span className="project-stat-item" title="文档数量">
|
<span className="project-stat-item" title="文档数量">
|
||||||
<FileTextOutlined /> {project.doc_count || 0} 文档
|
<FileTextOutlined /> {project.doc_count || 0} 文档
|
||||||
</span>
|
</span>
|
||||||
|
<span className="project-stat-item" title="最后更新时间">
|
||||||
|
<CalendarOutlined /> {formatRelativeTime(project.last_activity_at) || formatDate(project.created_at) || '—'}
|
||||||
|
</span>
|
||||||
<span className="project-card-owner" title={cardOwnerName(project)}>
|
<span className="project-card-owner" title={cardOwnerName(project)}>
|
||||||
<span className="project-card-owner-avatar">{cardOwnerInitial(project)}</span>
|
<span className="project-card-owner-avatar">{cardOwnerInitial(project)}</span>
|
||||||
<span className="project-card-owner-name">{cardOwnerName(project)}</span>
|
<span className="project-card-owner-name">{cardOwnerName(project)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={`project-card-role-badge ${cardRoleClass(project)}`} title={cardRoleLabel(project)}>
|
|
||||||
{cardRoleIcon(project)}
|
|
||||||
{cardRoleLabel(project)}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1145,13 +1174,25 @@ function ProjectList({ type = 'my' }) {
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
key: 'role',
|
key: 'role',
|
||||||
render: (role) => {
|
width: 130,
|
||||||
const roleMap = {
|
render: (role, record) => {
|
||||||
admin: '管理员',
|
const isOwner = record.user_id === currentProject?.owner_id
|
||||||
editor: '编辑者',
|
if (isOwner) {
|
||||||
viewer: '查看者',
|
return <Tag color="gold">所有者</Tag>
|
||||||
}
|
}
|
||||||
return roleMap[role] || role
|
return (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={role}
|
||||||
|
style={{ width: 110 }}
|
||||||
|
onChange={(newRole) => handleChangeMemberRole(record, newRole)}
|
||||||
|
options={[
|
||||||
|
{ value: 'admin', label: '管理员' },
|
||||||
|
{ value: 'editor', label: '编辑者' },
|
||||||
|
{ value: 'viewer', label: '查看者' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue