2025-12-20 11:18:59 +00:00
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
|
import { useNavigate } from 'react-router-dom'
|
2026-02-11 02:26:50 +00:00
|
|
|
|
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination } from 'antd'
|
2026-05-10 05:35:54 +00:00
|
|
|
|
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined } from '@ant-design/icons'
|
2026-02-09 10:08:38 +00:00
|
|
|
|
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
|
2026-05-09 02:45:30 +00:00
|
|
|
|
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
2025-12-29 12:53:50 +00:00
|
|
|
|
import { getUserList } from '@/api/users'
|
|
|
|
|
|
import { searchDocuments } from '@/api/search'
|
2025-12-20 11:18:59 +00:00
|
|
|
|
import ListActionBar from '@/components/ListActionBar/ListActionBar'
|
|
|
|
|
|
import Toast from '@/components/Toast/Toast'
|
|
|
|
|
|
import './ProjectList.css'
|
|
|
|
|
|
|
2025-12-29 12:53:50 +00:00
|
|
|
|
function ProjectList({ type = 'my' }) {
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const [projects, setProjects] = useState([])
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [modalVisible, setModalVisible] = useState(false)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
const [editModalVisible, setEditModalVisible] = useState(false)
|
2026-01-05 10:50:29 +00:00
|
|
|
|
const [gitModalVisible, setGitModalVisible] = useState(false)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
const [membersModalVisible, setMembersModalVisible] = useState(false)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const [currentProject, setCurrentProject] = useState(null)
|
|
|
|
|
|
const [shareInfo, setShareInfo] = useState(null)
|
|
|
|
|
|
const [hasPassword, setHasPassword] = useState(false)
|
|
|
|
|
|
const [password, setPassword] = useState('')
|
|
|
|
|
|
const [searchKeyword, setSearchKeyword] = useState('')
|
2025-12-29 12:53:50 +00:00
|
|
|
|
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)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const [form] = Form.useForm()
|
2025-12-29 12:53:50 +00:00
|
|
|
|
const [editForm] = Form.useForm()
|
2026-01-05 10:50:29 +00:00
|
|
|
|
const [gitForm] = Form.useForm()
|
2025-12-29 12:53:50 +00:00
|
|
|
|
const [memberForm] = Form.useForm()
|
2026-02-09 10:08:38 +00:00
|
|
|
|
const [transferModalVisible, setTransferModalVisible] = useState(false)
|
|
|
|
|
|
const [transferForm] = Form.useForm()
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const navigate = useNavigate()
|
2026-02-11 02:26:50 +00:00
|
|
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
|
|
|
|
const pageSize = 8
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchProjects()
|
2026-02-11 02:26:50 +00:00
|
|
|
|
setCurrentPage(1)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
}, [type])
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-02-11 02:26:50 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setCurrentPage(1)
|
|
|
|
|
|
}, [searchKeyword])
|
|
|
|
|
|
|
2026-02-09 10:08:38 +00:00
|
|
|
|
// ... (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))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const fetchProjects = async () => {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
try {
|
2025-12-29 12:53:50 +00:00
|
|
|
|
let res
|
|
|
|
|
|
if (type === 'my') {
|
|
|
|
|
|
res = await getOwnedProjects()
|
|
|
|
|
|
} else if (type === 'share') {
|
|
|
|
|
|
res = await getSharedProjects()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
res = await getMyProjects()
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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: '确认删除',
|
2025-12-29 12:53:50 +00:00
|
|
|
|
content: '确定要删除这个项目吗?如果项目中存在文件,将无法删除。删除后将无法恢复!',
|
|
|
|
|
|
okText: '确定删除',
|
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
|
cancelText: '取消',
|
2025-12-20 11:18:59 +00:00
|
|
|
|
onOk: async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteProject(projectId)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
Toast.success('删除成功', '项目已删除')
|
2025-12-20 11:18:59 +00:00
|
|
|
|
fetchProjects()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Delete project error:', error)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
const errorMsg = error.response?.data?.detail || error.message || '删除失败'
|
|
|
|
|
|
Toast.error('删除失败', errorMsg)
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 12:53:50 +00:00
|
|
|
|
// 打开编辑项目
|
|
|
|
|
|
const handleEdit = (e, project) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
setCurrentProject(project)
|
|
|
|
|
|
editForm.setFieldsValue({
|
|
|
|
|
|
name: project.name,
|
|
|
|
|
|
description: project.description,
|
|
|
|
|
|
is_public: project.is_public === 1,
|
|
|
|
|
|
})
|
2026-05-09 02:45:30 +00:00
|
|
|
|
setShareInfo(project.is_public === 1 ? null : { enabled: false, share_url: null, has_password: false, access_pass: null })
|
|
|
|
|
|
setHasPassword(false)
|
|
|
|
|
|
setPassword('')
|
2025-12-29 12:53:50 +00:00
|
|
|
|
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('项目更新失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 02:45:30 +00:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-01-05 10:50:29 +00:00
|
|
|
|
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 ? '更新失败' : '添加失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
const handleOpenProject = (projectId) => {
|
|
|
|
|
|
navigate(`/projects/${projectId}/docs`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 复制分享链接
|
2026-01-05 10:50:29 +00:00
|
|
|
|
const handleCopyLink = async () => {
|
2025-12-20 11:18:59 +00:00
|
|
|
|
if (!shareInfo) return
|
|
|
|
|
|
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
2026-01-05 10:50:29 +00:00
|
|
|
|
|
|
|
|
|
|
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('复制失败', '无法访问剪贴板')
|
|
|
|
|
|
}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换密码保护
|
|
|
|
|
|
const handlePasswordToggle = async (checked) => {
|
|
|
|
|
|
if (!checked) {
|
|
|
|
|
|
try {
|
2026-05-09 02:45:30 +00:00
|
|
|
|
await updateProjectShareSettings(currentProject.id, { access_pass: null })
|
2025-12-20 11:18:59 +00:00
|
|
|
|
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 {
|
2026-05-09 02:45:30 +00:00
|
|
|
|
await updateProjectShareSettings(currentProject.id, { access_pass: password })
|
2025-12-20 11:18:59 +00:00
|
|
|
|
message.success('访问密码已设置')
|
|
|
|
|
|
const res = await getProjectShareInfo(currentProject.id)
|
|
|
|
|
|
setShareInfo(res.data)
|
|
|
|
|
|
setHasPassword(true)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Save password error:', error)
|
|
|
|
|
|
message.error('设置密码失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 12:53:50 +00:00
|
|
|
|
// 打开成员管理
|
|
|
|
|
|
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') {
|
|
|
|
|
|
// 跳转到文件
|
2026-01-23 07:00:03 +00:00
|
|
|
|
navigate(`/projects/${item.project_id}/docs?file=${encodeURIComponent(item.file_path)}&keyword=${encodeURIComponent(searchKeyword)}`)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤项目(仅在未使用全局搜索时进行本地过滤)
|
|
|
|
|
|
const filteredProjects = !hasSearched && searchKeyword
|
|
|
|
|
|
? projects.filter((project) =>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
project.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
|
|
|
|
|
(project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase()))
|
|
|
|
|
|
)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
: projects
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-02-11 02:26:50 +00:00
|
|
|
|
const paginatedProjects = filteredProjects.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
|
|
|
|
|
|
2025-12-20 11:18:59 +00:00
|
|
|
|
return (
|
2025-12-29 12:53:50 +00:00
|
|
|
|
<div className="project-list-container">
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<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} 个结果
|
2025-12-29 12:53:50 +00:00
|
|
|
|
</div>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<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 && (
|
2026-02-11 02:26:50 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<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' ? [
|
2026-05-10 05:35:54 +00:00
|
|
|
|
<SettingOutlined key="settings" onClick={(e) => handleEdit(e, project)} />,
|
2026-02-11 02:26:50 +00:00
|
|
|
|
<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>
|
2025-12-29 12:53:50 +00:00
|
|
|
|
)}
|
2026-02-11 02:26:50 +00:00
|
|
|
|
<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>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
)}
|
2026-02-11 02:26:50 +00:00
|
|
|
|
</>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 搜索无结果提示 */}
|
|
|
|
|
|
{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}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<Form.Item
|
|
|
|
|
|
label="项目名称"
|
|
|
|
|
|
name="name"
|
|
|
|
|
|
rules={[{ required: true, message: '请输入项目名称' }]}
|
2025-12-20 11:18:59 +00:00
|
|
|
|
>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<Input placeholder="请输入项目名称" />
|
|
|
|
|
|
</Form.Item>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<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>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<Form.Item
|
|
|
|
|
|
label="项目描述"
|
|
|
|
|
|
name="description"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
placeholder="请输入项目描述(选填)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
label="公开项目"
|
|
|
|
|
|
name="is_public"
|
|
|
|
|
|
valuePropName="checked"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Switch />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
2026-05-09 02:45:30 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<Form.Item>
|
|
|
|
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
2026-02-09 10:08:38 +00:00
|
|
|
|
<Space>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
danger
|
|
|
|
|
|
icon={<DeleteOutlined />}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setEditModalVisible(false)
|
|
|
|
|
|
editForm.resetFields()
|
|
|
|
|
|
handleDeleteProject(currentProject.id)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
删除项目
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
danger
|
|
|
|
|
|
icon={<SwapOutlined />}
|
|
|
|
|
|
onClick={handleOpenTransfer}
|
|
|
|
|
|
>
|
|
|
|
|
|
转移所有权
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
<Space>
|
|
|
|
|
|
<Button type="primary" htmlType="submit">
|
2026-01-01 14:41:10 +00:00
|
|
|
|
更新
|
2025-12-20 11:18:59 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button onClick={() => {
|
2026-01-01 14:41:10 +00:00
|
|
|
|
setEditModalVisible(false)
|
|
|
|
|
|
editForm.resetFields()
|
2025-12-20 11:18:59 +00:00
|
|
|
|
}}>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
2026-02-09 10:08:38 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<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 }}
|
2025-12-29 12:53:50 +00:00
|
|
|
|
>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<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}
|
2025-12-29 12:53:50 +00:00
|
|
|
|
>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</div>
|
2025-12-29 12:53:50 +00:00
|
|
|
|
|
2026-01-01 14:41:10 +00:00
|
|
|
|
<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}`
|
2025-12-29 12:53:50 +00:00
|
|
|
|
},
|
2026-01-01 14:41:10 +00:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '角色',
|
|
|
|
|
|
dataIndex: 'role',
|
|
|
|
|
|
key: 'role',
|
|
|
|
|
|
render: (role) => {
|
|
|
|
|
|
const roleMap = {
|
|
|
|
|
|
admin: '管理员',
|
|
|
|
|
|
editor: '编辑者',
|
|
|
|
|
|
viewer: '查看者',
|
|
|
|
|
|
}
|
|
|
|
|
|
return roleMap[role] || role
|
2025-12-29 12:53:50 +00:00
|
|
|
|
},
|
2026-01-01 14:41:10 +00:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
2025-12-29 12:53:50 +00:00
|
|
|
|
},
|
2026-01-01 14:41:10 +00:00
|
|
|
|
},
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Modal>
|
2026-01-05 10:50:29 +00:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-01-01 14:41:10 +00:00
|
|
|
|
</div>
|
2025-12-20 11:18:59 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ProjectList
|