优化了部分UI

main
mula.liu 2026-08-28 12:44:42 +08:00
parent dfd46184b2
commit b3f5fb00b7
9 changed files with 363 additions and 68 deletions

View File

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

View File

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

View 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
*/ */

View File

@ -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 = () => {
if (!linkTarget) { const editor = editorCtxRef.current && editorCtxRef.current.editor
Toast.warning('提示', '请选择文件') if (!editor) return
return
} let linkText = ''
if (linkTab === 'page') {
if (editorCtxRef.current && editorCtxRef.current.editor) { if (!linkHeading) {
const editor = editorCtxRef.current.editor Toast.warning('提示', '请选择页内标题')
// return
const selection = editor.getSelection() }
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 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.focus()
} }
editor.replaceSelection(linkText)
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,26 +1471,62 @@ function DocumentEditor() {
onCancel={() => { onCancel={() => {
setLinkModalVisible(false) setLinkModalVisible(false)
setLinkTarget(null) setLinkTarget(null)
setLinkHeading(null)
}} }}
> >
<div> <Tabs
<p>选择要链接的文件</p> activeKey={linkTab}
<TreeSelect onChange={setLinkTab}
style={{ width: '100%' }} items={[
value={linkTarget} {
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }} key: 'page',
treeData={linkTreeData} label: '页内链接',
placeholder="请选择文件" children: (
treeDefaultExpandAll <div>
onChange={setLinkTarget} <p>选择页内标题插入锚点链接</p>
fieldNames={{ label: 'title', value: 'key', children: 'children' }} <Select
showSearch style={{ width: '100%' }}
filterTreeNode={(inputValue, treeNode) => { placeholder="请选择页内标题"
return Boolean(treeNode.isLeaf) && treeNode.title.toLowerCase().indexOf(inputValue.toLowerCase()) >= 0 value={linkHeading}
}} onChange={setLinkHeading}
allowClear showSearch
/> optionFilterProp="label"
</div> 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> </Modal>
</div> </div>
) )

View File

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

View File

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

View File

@ -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__'
// MDPDF/
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="目录索引">

View File

@ -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);
} }
/* ========== 搜索结果卡片(保持旧版居中风格) ========== */ /* ========== 搜索结果卡片(保持旧版居中风格) ========== */

View File

@ -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: '查看者' },
]}
/>
)
}, },
}, },
{ {