优化了部分UI
parent
dfd46184b2
commit
b3f5fb00b7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p>选择要链接的文件:</p>
|
||||
<TreeSelect
|
||||
style={{ width: '100%' }}
|
||||
value={linkTarget}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
treeData={linkTreeData}
|
||||
placeholder="请选择文件"
|
||||
treeDefaultExpandAll
|
||||
onChange={setLinkTarget}
|
||||
fieldNames={{ label: 'title', value: 'key', children: 'children' }}
|
||||
showSearch
|
||||
filterTreeNode={(inputValue, treeNode) => {
|
||||
return Boolean(treeNode.isLeaf) && treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<p>选择要链接的文件:</p>
|
||||
<TreeSelect
|
||||
style={{ width: '100%' }}
|
||||
value={linkTarget}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
treeData={linkTreeData}
|
||||
placeholder="请选择文件"
|
||||
treeDefaultExpandAll
|
||||
onChange={setLinkTarget}
|
||||
fieldNames={{ label: 'title', value: 'key', children: 'children' }}
|
||||
showSearch
|
||||
filterTreeNode={(inputValue, treeNode) => {
|
||||
return Boolean(treeNode.isLeaf) && treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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, ArrowLeftOutlined } from '@ant-design/icons'
|
||||
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'
|
||||
|
|
@ -22,11 +22,21 @@ import './PreviewPage.css'
|
|||
|
||||
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() {
|
||||
const { shareCode } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const contentRef = useRef(null)
|
||||
const largeMarkdownRef = useRef(null)
|
||||
const [headerVisible, setHeaderVisible] = useState(false)
|
||||
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
||||
const [shareInfo, setShareInfo] = useState(null)
|
||||
const [contentInfo, setContentInfo] = useState(null)
|
||||
|
|
@ -50,6 +60,42 @@ function FileSharePage() {
|
|||
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(() => {
|
||||
if (!markdownContent || isLargeMarkdown) {
|
||||
setTocItems([])
|
||||
|
|
@ -176,14 +222,14 @@ function FileSharePage() {
|
|||
<div className="file-share-shell">
|
||||
<Layout className="file-share-content-layout">
|
||||
<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
|
||||
type="button"
|
||||
className="project-back-button"
|
||||
onClick={handleClose}
|
||||
aria-label="返回"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
<h3 className="preview-header-title">
|
||||
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
|
||||
|
|
|
|||
|
|
@ -138,6 +138,14 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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, 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 remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
|
|
@ -29,6 +29,15 @@ import './PreviewPage.css'
|
|||
const { Sider, Content } = Layout
|
||||
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 }) => {
|
||||
if (!keyword || !text) return text
|
||||
return (
|
||||
|
|
@ -67,6 +76,7 @@ function ProjectSharePage() {
|
|||
const [isSearching, setIsSearching] = useState(false)
|
||||
const contentRef = useRef(null)
|
||||
const viewerRef = useRef(null)
|
||||
const [headerVisible, setHeaderVisible] = useState(false)
|
||||
const largeMarkdownRef = useRef(null)
|
||||
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
|
||||
const isLargeMarkdown = isLargeMarkdownContent(markdownContent)
|
||||
|
|
@ -86,6 +96,42 @@ function ProjectSharePage() {
|
|||
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(() => {
|
||||
if (viewerRef.current && viewMode === 'markdown' && !isLargeMarkdown) {
|
||||
const instance = new Mark(viewerRef.current)
|
||||
|
|
@ -633,9 +679,9 @@ function ProjectSharePage() {
|
|||
type="button"
|
||||
className="project-back-button"
|
||||
onClick={handleClose}
|
||||
aria-label="返回"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
<h2>{projectInfo?.name || '项目分享'}</h2>
|
||||
</div>
|
||||
|
|
@ -665,16 +711,16 @@ function ProjectSharePage() {
|
|||
|
||||
<Layout className="preview-content-layout">
|
||||
<Content className="preview-content" ref={contentRef}>
|
||||
<div className="preview-content-header">
|
||||
<div className={`preview-content-header${headerVisible ? '' : ' preview-header-hidden'}`}>
|
||||
{isMobile && (
|
||||
<div className="preview-header-leading-actions">
|
||||
<Tooltip title="返回">
|
||||
<Tooltip title="关闭">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleClose}
|
||||
size="small"
|
||||
aria-label="返回"
|
||||
aria-label="关闭"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="目录索引">
|
||||
|
|
|
|||
|
|
@ -311,11 +311,25 @@ body.dark .project-card-role-badge.role-editor {
|
|||
}
|
||||
|
||||
.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 { 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 { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, BranchesOutlined, GithubOutlined, GithubFilled, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, testGitRepoConnection, transferProject } from '@/api/project'
|
||||
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, updateProjectMemberRole, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, testGitRepoConnection, transferProject } from '@/api/project'
|
||||
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||||
import { getUserList } from '@/api/users'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
|
|
@ -50,6 +50,9 @@ function ProjectList({ type = 'my' }) {
|
|||
return roleMap[role] || role || '查看者'
|
||||
}
|
||||
|
||||
// 成员管理弹窗角色显示名
|
||||
const memberRoleLabels = { admin: '管理员', editor: '编辑者', viewer: '查看者' }
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
|
|
@ -78,6 +81,13 @@ function ProjectList({ type = 'my' }) {
|
|||
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) => {
|
||||
if (type === 'my') {
|
||||
|
|
@ -107,9 +117,9 @@ function ProjectList({ type = 'my' }) {
|
|||
|
||||
// 卡片角色徽章图标
|
||||
const cardRoleIcon = (project) => {
|
||||
if (type === 'my' || project.user_role === 'admin') return <CrownOutlined />
|
||||
if (project.user_role === 'editor') return <EditOutlined />
|
||||
return <EyeOutlined />
|
||||
if (type === 'my' || project.user_role === 'admin') return <SafetyOutlined />
|
||||
if (project.user_role === 'editor') return <FormOutlined />
|
||||
return <ReadOutlined />
|
||||
}
|
||||
|
||||
const {
|
||||
|
|
@ -184,7 +194,7 @@ function ProjectList({ type = 'my' }) {
|
|||
} else {
|
||||
res = await getMyProjects()
|
||||
}
|
||||
setProjects(res.data || [])
|
||||
setProjects(sortByLastActivity(res.data || []))
|
||||
// 参与项目:加载每个项目的未读更新通知数(与消息通知保持一致)
|
||||
if (type === 'share') {
|
||||
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) => {
|
||||
setSearchKeyword(value)
|
||||
|
|
@ -754,7 +779,12 @@ function ProjectList({ type = 'my' }) {
|
|||
<Tooltip key="kb" title="知识库向量化"><DatabaseOutlined onClick={(e) => handleKnowledge(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="文档数量">
|
||||
<FileTextOutlined /> {project.doc_count || 0} 文档
|
||||
</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-avatar">{cardOwnerInitial(project)}</span>
|
||||
<span className="project-card-owner-name">{cardOwnerName(project)}</span>
|
||||
</span>
|
||||
<span className={`project-card-role-badge ${cardRoleClass(project)}`} title={cardRoleLabel(project)}>
|
||||
{cardRoleIcon(project)}
|
||||
{cardRoleLabel(project)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1145,13 +1174,25 @@ function ProjectList({ type = 'my' }) {
|
|||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role) => {
|
||||
const roleMap = {
|
||||
admin: '管理员',
|
||||
editor: '编辑者',
|
||||
viewer: '查看者',
|
||||
width: 130,
|
||||
render: (role, record) => {
|
||||
const isOwner = record.user_id === currentProject?.owner_id
|
||||
if (isOwner) {
|
||||
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