优化了部分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="成员添加成功")
@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,

View File

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

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

View File

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

View File

@ -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
// 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() {
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} />

View File

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

View File

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

View File

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

View File

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