1145 lines
38 KiB
JavaScript
1145 lines
38 KiB
JavaScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd'
|
||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined } from '@ant-design/icons'
|
||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
|
||
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||
import { getUserList } from '@/api/users'
|
||
import { searchDocuments } from '@/api/search'
|
||
import ListActionBar from '@/components/ListActionBar/ListActionBar'
|
||
import Toast from '@/components/Toast/Toast'
|
||
import './ProjectList.css'
|
||
|
||
function ProjectList({ type = 'my' }) {
|
||
const [projects, setProjects] = useState([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [modalVisible, setModalVisible] = useState(false)
|
||
const [editModalVisible, setEditModalVisible] = useState(false)
|
||
const [gitModalVisible, setGitModalVisible] = useState(false)
|
||
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
||
const [currentProject, setCurrentProject] = useState(null)
|
||
const [shareInfo, setShareInfo] = useState(null)
|
||
const [hasPassword, setHasPassword] = useState(false)
|
||
const [password, setPassword] = useState('')
|
||
const [searchKeyword, setSearchKeyword] = useState('')
|
||
const [searchResults, setSearchResults] = useState([])
|
||
const [searching, setSearching] = useState(false)
|
||
const [hasSearched, setHasSearched] = useState(false)
|
||
const [members, setMembers] = useState([])
|
||
const [users, setUsers] = useState([])
|
||
const [loadingMembers, setLoadingMembers] = useState(false)
|
||
const [form] = Form.useForm()
|
||
const [editForm] = Form.useForm()
|
||
const [gitForm] = Form.useForm()
|
||
const [memberForm] = Form.useForm()
|
||
const [transferModalVisible, setTransferModalVisible] = useState(false)
|
||
const [transferForm] = Form.useForm()
|
||
const navigate = useNavigate()
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const pageSize = 8
|
||
|
||
useEffect(() => {
|
||
fetchProjects()
|
||
setCurrentPage(1)
|
||
}, [type])
|
||
|
||
useEffect(() => {
|
||
setCurrentPage(1)
|
||
}, [searchKeyword])
|
||
|
||
// ... (fetchProjects code)
|
||
|
||
const handleOpenTransfer = async () => {
|
||
setLoadingMembers(true)
|
||
setTransferModalVisible(true)
|
||
try {
|
||
// 获取用户列表 (排除自己)
|
||
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
|
||
const allUsers = res.data || []
|
||
// 过滤掉当前所有者(也就是自己,虽然API也会校验)
|
||
setUsers(allUsers.filter(u => u.id !== currentProject.owner_id))
|
||
} catch (error) {
|
||
message.error('加载用户列表失败')
|
||
} finally {
|
||
setLoadingMembers(false)
|
||
}
|
||
}
|
||
|
||
const handleTransfer = async (values) => {
|
||
Modal.confirm({
|
||
title: '确认转移',
|
||
content: '确定要将项目所有权转移给该用户吗?转移后您将变为管理员,无法再删除项目或转移所有权。',
|
||
okText: '确认转移',
|
||
okType: 'danger',
|
||
onOk: async () => {
|
||
try {
|
||
await transferProject(currentProject.id, values.new_owner_id)
|
||
message.success('项目所有权已转移')
|
||
setTransferModalVisible(false)
|
||
setEditModalVisible(false)
|
||
transferForm.resetFields()
|
||
fetchProjects()
|
||
} catch (error) {
|
||
console.error('Transfer error:', error)
|
||
message.error('转移失败: ' + (error.response?.data?.detail || error.message))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const fetchProjects = async () => {
|
||
setLoading(true)
|
||
try {
|
||
let res
|
||
if (type === 'my') {
|
||
res = await getOwnedProjects()
|
||
} else if (type === 'share') {
|
||
res = await getSharedProjects()
|
||
} else {
|
||
res = await getMyProjects()
|
||
}
|
||
setProjects(res.data || [])
|
||
} catch (error) {
|
||
console.error('Fetch projects error:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateProject = async (values) => {
|
||
try {
|
||
await createProject(values)
|
||
Toast.success('创建成功', '项目已创建')
|
||
setModalVisible(false)
|
||
form.resetFields()
|
||
fetchProjects()
|
||
} catch (error) {
|
||
console.error('Create project error:', error)
|
||
}
|
||
}
|
||
|
||
const handleDeleteProject = async (projectId) => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: '确定要删除这个项目吗?如果项目中存在文件,将无法删除。删除后将无法恢复!',
|
||
okText: '确定删除',
|
||
okType: 'danger',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await deleteProject(projectId)
|
||
Toast.success('删除成功', '项目已删除')
|
||
fetchProjects()
|
||
} catch (error) {
|
||
console.error('Delete project error:', error)
|
||
const errorMsg = error.response?.data?.detail || error.message || '删除失败'
|
||
Toast.error('删除失败', errorMsg)
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
// 打开编辑项目
|
||
const handleEdit = (e, project) => {
|
||
e.stopPropagation()
|
||
setCurrentProject(project)
|
||
editForm.setFieldsValue({
|
||
name: project.name,
|
||
description: project.description,
|
||
is_public: project.is_public === 1,
|
||
})
|
||
setShareInfo(project.is_public === 1 ? null : { enabled: false, share_url: null, has_password: false, access_pass: null })
|
||
setHasPassword(false)
|
||
setPassword('')
|
||
setEditModalVisible(true)
|
||
}
|
||
|
||
// 更新项目
|
||
const handleUpdateProject = async (values) => {
|
||
try {
|
||
await updateProject(currentProject.id, {
|
||
...values,
|
||
is_public: values.is_public ? 1 : 0,
|
||
})
|
||
message.success('项目更新成功')
|
||
setEditModalVisible(false)
|
||
editForm.resetFields()
|
||
fetchProjects()
|
||
} catch (error) {
|
||
console.error('Update project error:', error)
|
||
message.error('项目更新失败')
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!editModalVisible || !currentProject) return
|
||
|
||
const isPublic = editForm.getFieldValue('is_public')
|
||
if (!isPublic) {
|
||
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
|
||
return
|
||
}
|
||
|
||
;(async () => {
|
||
try {
|
||
const res = await getProjectShareInfo(currentProject.id)
|
||
setShareInfo(res.data)
|
||
setHasPassword(res.data.has_password)
|
||
setPassword(res.data.access_pass || '')
|
||
} catch (error) {
|
||
console.error('Get project share info error:', error)
|
||
}
|
||
})()
|
||
}, [editModalVisible, currentProject, editForm])
|
||
|
||
const currentEditPublic = Form.useWatch('is_public', editForm)
|
||
const isPublicEnablePending = editModalVisible && currentEditPublic && currentProject?.is_public !== 1
|
||
|
||
useEffect(() => {
|
||
if (!editModalVisible || !currentProject) return
|
||
|
||
if (!currentEditPublic) {
|
||
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
|
||
setHasPassword(false)
|
||
setPassword('')
|
||
return
|
||
}
|
||
|
||
if (currentProject.is_public !== 1) {
|
||
setShareInfo(null)
|
||
setHasPassword(false)
|
||
setPassword('')
|
||
return
|
||
}
|
||
|
||
;(async () => {
|
||
try {
|
||
const res = await getProjectShareInfo(currentProject.id)
|
||
setShareInfo(res.data)
|
||
setHasPassword(res.data.has_password)
|
||
setPassword(res.data.access_pass || '')
|
||
} catch (error) {
|
||
console.error('Refresh project share info error:', error)
|
||
}
|
||
})()
|
||
}, [currentEditPublic, editModalVisible, currentProject, editForm])
|
||
|
||
const [gitRepos, setGitRepos] = useState([])
|
||
const [loadingRepos, setLoadingRepos] = useState(false)
|
||
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
|
||
const [editingRepo, setEditingRepo] = useState(null)
|
||
const [repoForm] = Form.useForm()
|
||
|
||
// 打开Git设置(仓库列表)
|
||
const handleGitSettings = (e, project) => {
|
||
e.stopPropagation()
|
||
setCurrentProject(project)
|
||
setGitModalVisible(true)
|
||
fetchGitRepos(project.id)
|
||
}
|
||
|
||
// 加载Git仓库列表
|
||
const fetchGitRepos = async (projectId) => {
|
||
setLoadingRepos(true)
|
||
try {
|
||
const res = await getGitRepos(projectId)
|
||
setGitRepos(res.data || [])
|
||
} catch (error) {
|
||
console.error('Fetch git repos error:', error)
|
||
message.error('加载Git仓库失败')
|
||
} finally {
|
||
setLoadingRepos(false)
|
||
}
|
||
}
|
||
|
||
// 打开添加仓库弹窗
|
||
const handleAddRepo = () => {
|
||
setEditingRepo(null)
|
||
repoForm.resetFields()
|
||
// 如果是第一个仓库,默认设为默认
|
||
if (gitRepos.length === 0) {
|
||
repoForm.setFieldsValue({ is_default: 1 })
|
||
}
|
||
setGitRepoModalVisible(true)
|
||
}
|
||
|
||
// 打开编辑仓库弹窗
|
||
const handleEditRepo = (repo) => {
|
||
setEditingRepo(repo)
|
||
repoForm.setFieldsValue({
|
||
...repo,
|
||
is_default: repo.is_default === 1,
|
||
})
|
||
setGitRepoModalVisible(true)
|
||
}
|
||
|
||
// 删除仓库
|
||
const handleDeleteRepo = (repoId) => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: '确定要删除这个Git仓库配置吗?',
|
||
onOk: async () => {
|
||
try {
|
||
await deleteGitRepo(currentProject.id, repoId)
|
||
message.success('删除成功')
|
||
fetchGitRepos(currentProject.id)
|
||
} catch (error) {
|
||
console.error('Delete repo error:', error)
|
||
message.error('删除失败')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
// 保存仓库(新增/更新)
|
||
const handleSaveRepo = async (values) => {
|
||
try {
|
||
const data = {
|
||
...values,
|
||
is_default: values.is_default ? 1 : 0,
|
||
}
|
||
|
||
if (editingRepo) {
|
||
await updateGitRepo(currentProject.id, editingRepo.id, data)
|
||
message.success('更新成功')
|
||
} else {
|
||
await createGitRepo(currentProject.id, data)
|
||
message.success('添加成功')
|
||
}
|
||
|
||
setGitRepoModalVisible(false)
|
||
fetchGitRepos(currentProject.id)
|
||
} catch (error) {
|
||
console.error('Save repo error:', error)
|
||
message.error(editingRepo ? '更新失败' : '添加失败')
|
||
}
|
||
}
|
||
|
||
const handleOpenProject = (projectId) => {
|
||
navigate(`/projects/${projectId}/docs`)
|
||
}
|
||
|
||
// 复制分享链接
|
||
const handleCopyLink = async () => {
|
||
if (!shareInfo) return
|
||
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||
|
||
try {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(fullUrl)
|
||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||
} else {
|
||
// Fallback for non-secure contexts or older browsers
|
||
const textArea = document.createElement("textarea")
|
||
textArea.value = fullUrl
|
||
textArea.style.position = "fixed"
|
||
textArea.style.left = "-9999px"
|
||
textArea.style.top = "0"
|
||
document.body.appendChild(textArea)
|
||
textArea.focus()
|
||
textArea.select()
|
||
const successful = document.execCommand('copy')
|
||
document.body.removeChild(textArea)
|
||
if (successful) {
|
||
Toast.success('复制成功', '分享链接已复制到剪贴板')
|
||
} else {
|
||
Toast.error('复制失败', '请手动复制链接')
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to copy:', err)
|
||
Toast.error('复制失败', '无法访问剪贴板')
|
||
}
|
||
}
|
||
|
||
// 切换密码保护
|
||
const handlePasswordToggle = async (checked) => {
|
||
if (!checked) {
|
||
try {
|
||
await updateProjectShareSettings(currentProject.id, { access_pass: null })
|
||
setHasPassword(false)
|
||
setPassword('')
|
||
message.success('已取消访问密码')
|
||
const res = await getProjectShareInfo(currentProject.id)
|
||
setShareInfo(res.data)
|
||
} catch (error) {
|
||
console.error('Update settings error:', error)
|
||
message.error('操作失败')
|
||
}
|
||
} else {
|
||
setHasPassword(true)
|
||
}
|
||
}
|
||
|
||
// 保存密码
|
||
const handleSavePassword = async () => {
|
||
if (!password.trim()) {
|
||
message.warning('请输入访问密码')
|
||
return
|
||
}
|
||
try {
|
||
await updateProjectShareSettings(currentProject.id, { access_pass: password })
|
||
message.success('访问密码已设置')
|
||
const res = await getProjectShareInfo(currentProject.id)
|
||
setShareInfo(res.data)
|
||
setHasPassword(true)
|
||
} catch (error) {
|
||
console.error('Save password error:', error)
|
||
message.error('设置密码失败')
|
||
}
|
||
}
|
||
|
||
// 打开成员管理
|
||
const handleMembers = async (e, project) => {
|
||
e.stopPropagation()
|
||
setCurrentProject(project)
|
||
setMembersModalVisible(true)
|
||
setLoadingMembers(true)
|
||
|
||
try {
|
||
// 并行加载成员列表和用户列表(只获取普通用户 role_id=3)
|
||
const [membersRes, usersRes] = await Promise.all([
|
||
getProjectMembers(project.id),
|
||
getUserList({ page: 1, page_size: 100, status: 1, role_id: 3 })
|
||
])
|
||
|
||
console.log('Members Response:', membersRes)
|
||
console.log('Users Response:', usersRes)
|
||
|
||
const membersData = membersRes.data || []
|
||
// 后端返回格式: { code: 200, message: "success", data: [...], total, page, page_size }
|
||
const usersData = Array.isArray(usersRes.data) ? usersRes.data : []
|
||
|
||
console.log('Setting members:', membersData)
|
||
console.log('Setting users:', usersData)
|
||
|
||
setMembers(membersData)
|
||
setUsers(usersData)
|
||
} catch (error) {
|
||
console.error('Get members error:', error)
|
||
console.error('Error details:', error.response)
|
||
message.error('获取数据失败: ' + (error.response?.data?.detail || error.message))
|
||
} finally {
|
||
setLoadingMembers(false)
|
||
}
|
||
}
|
||
|
||
// 添加成员
|
||
const handleAddMember = async (values) => {
|
||
try {
|
||
await addProjectMember(currentProject.id, values)
|
||
message.success('成员添加成功')
|
||
memberForm.resetFields()
|
||
// 刷新成员列表(带用户名信息)
|
||
const res = await getProjectMembers(currentProject.id)
|
||
setMembers(res.data || [])
|
||
} catch (error) {
|
||
console.error('Add member error:', error)
|
||
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
|
||
message.error(errorMsg)
|
||
}
|
||
}
|
||
|
||
// 删除成员
|
||
const handleRemoveMember = async (userId) => {
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: '确定要删除这个成员吗?',
|
||
onOk: async () => {
|
||
try {
|
||
await removeProjectMember(currentProject.id, userId)
|
||
message.success('成员删除成功')
|
||
// 刷新成员列表(带用户名信息)
|
||
const res = await getProjectMembers(currentProject.id)
|
||
setMembers(res.data || [])
|
||
} catch (error) {
|
||
console.error('Remove member error:', error)
|
||
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
|
||
message.error(errorMsg)
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
// 处理搜索输入变化
|
||
const handleSearchChange = (value) => {
|
||
setSearchKeyword(value)
|
||
// 如果清空了输入框,重置搜索状态
|
||
if (!value || !value.trim()) {
|
||
setSearchResults([])
|
||
setHasSearched(false)
|
||
}
|
||
}
|
||
|
||
// 处理搜索
|
||
const handleSearch = async (keyword) => {
|
||
setSearchKeyword(keyword)
|
||
|
||
if (!keyword || !keyword.trim()) {
|
||
// 清空搜索,显示所有项目
|
||
setSearchResults([])
|
||
setSearching(false)
|
||
setHasSearched(false)
|
||
return
|
||
}
|
||
|
||
setSearching(true)
|
||
setHasSearched(true)
|
||
try {
|
||
const res = await searchDocuments(keyword.trim())
|
||
setSearchResults(res.data || [])
|
||
} catch (error) {
|
||
console.error('Search error:', error)
|
||
Toast.error('搜索失败', error.response?.data?.detail || error.message)
|
||
} finally {
|
||
setSearching(false)
|
||
}
|
||
}
|
||
|
||
// 处理搜索结果点击
|
||
const handleSearchResultClick = (item) => {
|
||
if (item.type === 'project') {
|
||
// 跳转到项目文档页
|
||
navigate(`/projects/${item.project_id}/docs`)
|
||
} else if (item.type === 'file') {
|
||
// 跳转到文件
|
||
navigate(`/projects/${item.project_id}/docs?file=${encodeURIComponent(item.file_path)}&keyword=${encodeURIComponent(searchKeyword)}`)
|
||
}
|
||
}
|
||
|
||
// 过滤项目(仅在未使用全局搜索时进行本地过滤)
|
||
const filteredProjects = !hasSearched && searchKeyword
|
||
? projects.filter((project) =>
|
||
project.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||
(project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase()))
|
||
)
|
||
: projects
|
||
|
||
const paginatedProjects = filteredProjects.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||
|
||
return (
|
||
<div className="project-list-container">
|
||
<ListActionBar
|
||
actions={type === 'my' ? [
|
||
{
|
||
key: 'create',
|
||
label: '创建项目',
|
||
type: 'primary',
|
||
icon: <PlusOutlined />,
|
||
onClick: () => setModalVisible(true),
|
||
},
|
||
] : []}
|
||
search={{
|
||
placeholder: '搜索项目或文件...',
|
||
value: searchKeyword,
|
||
onChange: handleSearchChange,
|
||
onSearch: handleSearch,
|
||
}}
|
||
showRefresh
|
||
onRefresh={fetchProjects}
|
||
/>
|
||
|
||
{/* 搜索结果 */}
|
||
{hasSearched && searchResults.length > 0 && (
|
||
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||
<div style={{ marginBottom: 8, color: '#666' }}>
|
||
找到 {searchResults.length} 个结果
|
||
</div>
|
||
<Row gutter={[16, 16]}>
|
||
{searchResults.map((item, index) => (
|
||
<Col xs={24} sm={12} md={8} lg={6} key={`${item.type}-${item.project_id}-${index}`}>
|
||
<Card
|
||
hoverable
|
||
className="project-card"
|
||
onClick={() => handleSearchResultClick(item)}
|
||
>
|
||
<div className="project-card-icon">
|
||
{item.type === 'project' ? (
|
||
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||
) : (
|
||
<FileOutlined style={{ fontSize: 48, color: '#52c41a' }} />
|
||
)}
|
||
</div>
|
||
<Space direction="vertical" style={{ width: '100%' }}>
|
||
<Space>
|
||
<h3 style={{ margin: 0 }}>
|
||
{item.type === 'project' ? item.project_name : item.file_name}
|
||
</h3>
|
||
<Tag color={item.type === 'project' ? 'blue' : 'green'}>
|
||
{item.match_type}
|
||
</Tag>
|
||
</Space>
|
||
{item.type === 'project' && (
|
||
<p className="project-description">
|
||
{item.project_description || '暂无描述'}
|
||
</p>
|
||
)}
|
||
{item.type === 'file' && (
|
||
<div style={{ fontSize: 12, color: '#666' }}>
|
||
<div>项目: {item.project_name}</div>
|
||
<div>路径: {item.file_path}</div>
|
||
</div>
|
||
)}
|
||
</Space>
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
</div>
|
||
)}
|
||
|
||
{/* 正常项目列表 */}
|
||
{!hasSearched && (
|
||
<>
|
||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||
{paginatedProjects.map((project) => (
|
||
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
|
||
<Card
|
||
hoverable
|
||
className="project-card"
|
||
onClick={() => handleOpenProject(project.id)}
|
||
actions={type === 'my' ? [
|
||
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
|
||
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
|
||
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
|
||
] : [
|
||
<EyeOutlined key="view" />,
|
||
]}
|
||
>
|
||
{/* 公开项目标识 */}
|
||
{project.is_public === 1 && (
|
||
<div className="project-card-public-badge">公开</div>
|
||
)}
|
||
<div className="project-card-icon">
|
||
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||
</div>
|
||
<h3>{project.name}</h3>
|
||
<p className="project-description">{project.description || '暂无描述'}</p>
|
||
<div className="project-meta">
|
||
<span>文档数: {project.doc_count || 0}</span>
|
||
{type === 'share' && project.owner_name && (
|
||
<span style={{ marginLeft: 12 }}>
|
||
所有者: {project.owner_nickname || project.owner_name}
|
||
</span>
|
||
)}
|
||
{type === 'share' && project.user_role && (
|
||
<span style={{ marginLeft: 12 }}>
|
||
角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
|
||
{filteredProjects.length === 0 && !loading && (
|
||
<Col span={24}>
|
||
<Empty description={type === 'my' ? "还没有项目,创建一个开始吧" : "还没有参与的项目"} />
|
||
</Col>
|
||
)}
|
||
</Row>
|
||
{filteredProjects.length > 0 && (
|
||
<div style={{ marginTop: 24, display: 'flex', justifyContent: 'center' }}>
|
||
<Pagination
|
||
current={currentPage}
|
||
pageSize={pageSize}
|
||
total={filteredProjects.length}
|
||
onChange={setCurrentPage}
|
||
showSizeChanger={false}
|
||
className="dot-pagination"
|
||
itemRender={(page, type, originalElement) => {
|
||
if (type === 'page') {
|
||
return <span className="pagination-dot" />
|
||
}
|
||
return originalElement
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 搜索无结果提示 */}
|
||
{hasSearched && !searching && searchResults.length === 0 && (
|
||
<div style={{ marginTop: 16 }}>
|
||
<Empty description={`没有找到包含 "${searchKeyword}" 的项目或文件`} />
|
||
</div>
|
||
)}
|
||
|
||
<Modal
|
||
title="创建新项目"
|
||
open={modalVisible}
|
||
onCancel={() => {
|
||
setModalVisible(false)
|
||
form.resetFields()
|
||
}}
|
||
footer={null}
|
||
>
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
onFinish={handleCreateProject}
|
||
>
|
||
<Form.Item
|
||
label="项目名称"
|
||
name="name"
|
||
rules={[{ required: true, message: '请输入项目名称' }]}
|
||
>
|
||
<Input placeholder="请输入项目名称" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="项目描述"
|
||
name="description"
|
||
>
|
||
<Input.TextArea
|
||
rows={4}
|
||
placeholder="请输入项目描述(选填)"
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit">
|
||
创建
|
||
</Button>
|
||
<Button onClick={() => {
|
||
setModalVisible(false)
|
||
form.resetFields()
|
||
}}>
|
||
取消
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="编辑项目"
|
||
open={editModalVisible}
|
||
onCancel={() => {
|
||
setEditModalVisible(false)
|
||
editForm.resetFields()
|
||
}}
|
||
footer={null}
|
||
>
|
||
<Form
|
||
form={editForm}
|
||
layout="vertical"
|
||
onFinish={handleUpdateProject}
|
||
>
|
||
<Form.Item
|
||
label="项目名称"
|
||
name="name"
|
||
rules={[{ required: true, message: '请输入项目名称' }]}
|
||
>
|
||
<Input placeholder="请输入项目名称" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="项目描述"
|
||
name="description"
|
||
>
|
||
<Input.TextArea
|
||
rows={4}
|
||
placeholder="请输入项目描述(选填)"
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="公开项目"
|
||
name="is_public"
|
||
valuePropName="checked"
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
|
||
<Form.Item label="项目公开分享">
|
||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||
{!editForm.getFieldValue('is_public') ? (
|
||
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
|
||
开启“公开项目”后,才可以生成项目分享链接和配置访问密码。
|
||
</div>
|
||
) : isPublicEnablePending ? (
|
||
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
|
||
保存项目后将自动生成新的项目分享链接。
|
||
</div>
|
||
) : (
|
||
<>
|
||
<Input
|
||
value={shareInfo?.share_url ? `${window.location.origin}${shareInfo.share_url}` : '正在生成分享链接...'}
|
||
readOnly
|
||
addonAfter={
|
||
shareInfo?.share_url ? <CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} /> : null
|
||
}
|
||
/>
|
||
<Space>
|
||
<span style={{ fontWeight: 500 }}>访问密码保护</span>
|
||
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
|
||
</Space>
|
||
{hasPassword && (
|
||
<div>
|
||
<Input.Password
|
||
placeholder="请输入访问密码"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
/>
|
||
<Button type="primary" onClick={handleSavePassword} style={{ marginTop: 8 }}>
|
||
保存密码
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</Space>
|
||
</Form.Item>
|
||
|
||
<Form.Item>
|
||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||
<Space>
|
||
<Button
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
onClick={() => {
|
||
setEditModalVisible(false)
|
||
editForm.resetFields()
|
||
handleDeleteProject(currentProject.id)
|
||
}}
|
||
>
|
||
删除项目
|
||
</Button>
|
||
<Button
|
||
danger
|
||
icon={<SwapOutlined />}
|
||
onClick={handleOpenTransfer}
|
||
>
|
||
转移所有权
|
||
</Button>
|
||
</Space>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit">
|
||
更新
|
||
</Button>
|
||
<Button onClick={() => {
|
||
setEditModalVisible(false)
|
||
editForm.resetFields()
|
||
}}>
|
||
取消
|
||
</Button>
|
||
</Space>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="转移项目所有权"
|
||
open={transferModalVisible}
|
||
onCancel={() => {
|
||
setTransferModalVisible(false)
|
||
transferForm.resetFields()
|
||
}}
|
||
onOk={() => transferForm.submit()}
|
||
confirmLoading={loadingMembers}
|
||
>
|
||
<Form
|
||
form={transferForm}
|
||
layout="vertical"
|
||
onFinish={handleTransfer}
|
||
>
|
||
<p style={{ color: '#f5222d', marginBottom: 16 }}>
|
||
警告:转移所有权后,您将失去该项目的所有权并变为普通管理员,且无法撤销此操作。
|
||
</p>
|
||
<Form.Item
|
||
name="new_owner_id"
|
||
label="选择新所有者"
|
||
rules={[{ required: true, message: '请选择新所有者' }]}
|
||
>
|
||
<Select
|
||
placeholder="搜索用户"
|
||
showSearch
|
||
loading={loadingMembers}
|
||
filterOption={(input, option) =>
|
||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||
}
|
||
options={users.map(user => ({
|
||
value: user.id,
|
||
label: user.nickname ? `${user.username} (${user.nickname})` : user.username,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="成员管理"
|
||
open={membersModalVisible}
|
||
onCancel={() => {
|
||
setMembersModalVisible(false)
|
||
memberForm.resetFields()
|
||
setMembers([])
|
||
setUsers([])
|
||
}}
|
||
footer={null}
|
||
width={700}
|
||
>
|
||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||
<div>
|
||
{users.length === 0 ? (
|
||
<div style={{ marginBottom: 16, color: '#999' }}>
|
||
{loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'}
|
||
</div>) : null}
|
||
<Form
|
||
form={memberForm}
|
||
layout="inline"
|
||
onFinish={handleAddMember}
|
||
>
|
||
<Form.Item
|
||
name="user_id"
|
||
rules={[{ required: true, message: '请选择用户' }]}
|
||
style={{ width: 250 }}
|
||
>
|
||
<Select
|
||
placeholder={users.length > 0 ? "选择用户" : "没有可添加的用户"}
|
||
showSearch
|
||
loading={loadingMembers}
|
||
disabled={users.length === 0}
|
||
filterOption={(input, option) =>
|
||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||
}
|
||
options={users
|
||
.filter(user => !members.some(m => m.user_id === user.id))
|
||
.map(user => ({
|
||
value: user.id,
|
||
label: user.nickname ? `${user.username} (${user.nickname})` : user.username,
|
||
}))}
|
||
notFoundContent={loadingMembers ? "加载中..." : "没有找到用户"}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="role"
|
||
rules={[{ required: true, message: '请选择角色' }]}
|
||
initialValue="viewer"
|
||
style={{ width: 150 }}
|
||
>
|
||
<Select placeholder="选择角色">
|
||
<Select.Option value="admin">管理员</Select.Option>
|
||
<Select.Option value="editor">编辑者</Select.Option>
|
||
<Select.Option value="viewer">查看者</Select.Option>
|
||
</Select>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button
|
||
type="primary"
|
||
htmlType="submit"
|
||
loading={loadingMembers}
|
||
disabled={users.length === 0}
|
||
>
|
||
添加
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
</div>
|
||
|
||
<div>
|
||
<h4>当前成员</h4>
|
||
{members.length === 0 ? (
|
||
<Empty description="暂无成员" />
|
||
) : (
|
||
<Table
|
||
dataSource={members}
|
||
rowKey="id"
|
||
pagination={false}
|
||
loading={loadingMembers}
|
||
columns={[
|
||
{
|
||
title: '用户',
|
||
dataIndex: 'username',
|
||
key: 'username',
|
||
render: (username, record) => {
|
||
if (record.nickname) {
|
||
return `${username} (${record.nickname})`
|
||
}
|
||
return username || `用户ID: ${record.user_id}`
|
||
},
|
||
},
|
||
{
|
||
title: '角色',
|
||
dataIndex: 'role',
|
||
key: 'role',
|
||
render: (role) => {
|
||
const roleMap = {
|
||
admin: '管理员',
|
||
editor: '编辑者',
|
||
viewer: '查看者',
|
||
}
|
||
return roleMap[role] || role
|
||
},
|
||
},
|
||
{
|
||
title: '加入时间',
|
||
dataIndex: 'joined_at',
|
||
key: 'joined_at',
|
||
render: (time) => time ? new Date(time).toLocaleString('zh-CN') : '-',
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
render: (_, record) => {
|
||
const isOwner = record.user_id === currentProject?.owner_id
|
||
return (
|
||
<Button
|
||
type="link"
|
||
danger
|
||
onClick={() => handleRemoveMember(record.user_id)}
|
||
disabled={isOwner}
|
||
title={isOwner ? '不能删除项目所有者' : '删除成员'}
|
||
>
|
||
{isOwner ? '所有者' : '删除'}
|
||
</Button>
|
||
)
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
</div>
|
||
</Space>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="Git仓库管理"
|
||
open={gitModalVisible}
|
||
onCancel={() => {
|
||
setGitModalVisible(false)
|
||
setGitRepos([])
|
||
}}
|
||
footer={null}
|
||
width={800}
|
||
>
|
||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddRepo}>
|
||
添加仓库
|
||
</Button>
|
||
</div>
|
||
|
||
<Table
|
||
dataSource={gitRepos}
|
||
rowKey="id"
|
||
loading={loadingRepos}
|
||
pagination={false}
|
||
columns={[
|
||
{
|
||
title: '别名',
|
||
dataIndex: 'name',
|
||
key: 'name',
|
||
render: (text, record) => (
|
||
<Space>
|
||
{text}
|
||
{record.is_default === 1 && <Tag color="blue">默认</Tag>}
|
||
</Space>
|
||
)
|
||
},
|
||
{
|
||
title: '仓库地址',
|
||
dataIndex: 'repo_url',
|
||
key: 'repo_url',
|
||
ellipsis: true,
|
||
},
|
||
{
|
||
title: '分支',
|
||
dataIndex: 'branch',
|
||
key: 'branch',
|
||
width: 100,
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 150,
|
||
render: (_, record) => (
|
||
<Space>
|
||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditRepo(record)}>编辑</Button>
|
||
<Button type="link" danger size="small" icon={<DeleteOutlined />} onClick={() => handleDeleteRepo(record.id)}>删除</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={editingRepo ? "编辑仓库" : "添加仓库"}
|
||
open={gitRepoModalVisible}
|
||
onCancel={() => {
|
||
setGitRepoModalVisible(false)
|
||
repoForm.resetFields()
|
||
}}
|
||
footer={null}
|
||
>
|
||
<Form
|
||
form={repoForm}
|
||
layout="vertical"
|
||
onFinish={handleSaveRepo}
|
||
>
|
||
<Form.Item
|
||
label="仓库别名"
|
||
name="name"
|
||
rules={[{ required: true, message: '请输入别名(如:Origin, Backup)' }]}
|
||
>
|
||
<Input placeholder="例如:Origin" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="Git仓库地址"
|
||
name="repo_url"
|
||
rules={[{ required: true, message: '请输入Git仓库地址' }, { type: 'url', message: '请输入有效的URL' }]}
|
||
extra="支持 HTTPS 协议"
|
||
>
|
||
<Input placeholder="https://github.com/username/repo.git" prefix={<GithubOutlined />} />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="分支"
|
||
name="branch"
|
||
initialValue="main"
|
||
>
|
||
<Input placeholder="main" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="用户名"
|
||
name="username"
|
||
extra="私有仓库需填写"
|
||
>
|
||
<Input placeholder="Git用户名" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="Token/密码"
|
||
name="token"
|
||
extra="推荐使用 Personal Access Token"
|
||
>
|
||
<Input.Password placeholder="Git访问令牌" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="is_default"
|
||
valuePropName="checked"
|
||
>
|
||
<Switch checkedChildren="默认仓库" unCheckedChildren="非默认" />
|
||
</Form.Item>
|
||
|
||
<Form.Item>
|
||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||
<Button onClick={() => setGitRepoModalVisible(false)}>取消</Button>
|
||
<Button type="primary" htmlType="submit">保存</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ProjectList
|