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, Progress, Alert, List, Spin, Tooltip } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, 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, transferProject } from '@/api/project'
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
import { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search'
import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification'
import useNotificationStore from '@/stores/notificationStore'
import ListActionBar from '@/components/ListActionBar/ListActionBar'
import Toast from '@/components/Toast/Toast'
import useProjectKnowledge from './useProjectKnowledge'
import useUserStore from '@/stores/userStore'
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 { user: currentUser } = useUserStore()
const [currentPage, setCurrentPage] = useState(1)
const [unreadByProject, setUnreadByProject] = useState({})
const pageSize = 8
// 参与项目角色 -> 显示名称
const projectRoleLabel = (role) => {
const roleMap = { admin: '管理者', editor: '编辑者', viewer: '查看者' }
return roleMap[role] || role || '查看者'
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return ''
try {
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString('zh-CN')
} catch {
return ''
}
}
// 相对时间(用于“最后更新”)
const formatRelativeTime = (dateStr) => {
if (!dateStr) return ''
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return ''
const diff = Date.now() - d.getTime()
if (diff < 60 * 1000) return '刚刚'
const minutes = Math.floor(diff / (60 * 1000))
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
if (days < 30) return `${days} 天前`
return formatDate(dateStr)
}
// 卡片归属用户显示名(我的项目 -> 当前用户;参与项目 -> 项目所有者)
const cardOwnerName = (project) => {
if (type === 'my') {
return currentUser?.nickname || currentUser?.username || '我'
}
return project.owner_nickname || project.owner_name || '未知'
}
// 卡片归属用户头像首字符
const cardOwnerInitial = (project) => {
const name = cardOwnerName(project)
return (name || '?').charAt(0).toUpperCase()
}
// 卡片角色显示名(我的项目 -> 所有者;参与项目 -> 我的角色)
const cardRoleLabel = (project) => {
if (type === 'my') return '所有者'
return projectRoleLabel(project.user_role)
}
// 卡片角色徽章样式类
const cardRoleClass = (project) => {
if (type === 'my') return 'role-owner'
const roleMap = { admin: 'role-admin', editor: 'role-editor', viewer: 'role-viewer' }
return roleMap[project.user_role] || 'role-viewer'
}
// 卡片角色徽章图标
const cardRoleIcon = (project) => {
if (type === 'my' || project.user_role === 'admin') return
if (project.user_role === 'editor') return
return
}
const {
kbModalVisible,
progress,
currentTask,
loadingProgress,
vectorizing,
openKnowledgeModal,
closeKnowledgeModal,
refreshProgress,
runVectorize,
} = useProjectKnowledge()
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 || [])
// 参与项目:加载每个项目的未读更新通知数(与消息通知保持一致)
if (type === 'share') {
getUnreadByProject()
.then((unreadRes) => setUnreadByProject(unreadRes.data?.unread_by_project || {}))
.catch(() => setUnreadByProject({}))
} else {
setUnreadByProject({})
}
} 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,
})
setShareInfo(null)
setHasPassword(false)
setPassword('')
setEditModalVisible(true)
}
// 切换公开/私有(立即生效)
const handlePublicToggle = async (checked) => {
if (!currentProject) return
try {
const res = await updateProject(currentProject.id, {
is_public: checked ? 1 : 0,
})
const updatedProject = res.data || {
...currentProject,
is_public: checked ? 1 : 0,
}
setCurrentProject(updatedProject)
message.success(checked ? '项目已设为公开' : '项目已设为私有')
await fetchProjects()
if (checked) {
const shareRes = await getProjectShareInfo(currentProject.id)
setShareInfo(shareRes.data)
setHasPassword(shareRes.data.has_password)
setPassword(shareRes.data.access_pass || '')
} else {
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
setHasPassword(false)
setPassword('')
}
} catch (error) {
console.error('Toggle public error:', error)
message.error('操作失败')
}
}
// 更新项目(仅名称和描述)
const handleUpdateProject = async (values) => {
try {
const res = await updateProject(currentProject.id, {
name: values.name,
description: values.description,
})
const updatedProject = res.data || {
...currentProject,
...values,
}
setCurrentProject(updatedProject)
message.success('项目更新成功')
await fetchProjects()
setEditModalVisible(false)
editForm.resetFields()
} catch (error) {
console.error('Update project error:', error)
message.error('项目更新失败')
}
}
useEffect(() => {
if (!editModalVisible || !currentProject) return
if (currentProject.is_public !== 1) {
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: 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('Get project share info error:', error)
}
})()
}, [editModalVisible, currentProject])
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)
}
// 打开知识库(向量化进度)弹窗
const handleKnowledge = (e, project) => {
e.stopPropagation()
setCurrentProject(project)
openKnowledgeModal(project)
}
// 加载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) => {
// 打开参与项目时,将其未读更新通知标记为已读(与消息通知联动)
if (type === 'share' && unreadByProject[projectId]) {
markProjectNotificationsRead(projectId)
.then(() => {
setUnreadByProject((prev) => {
if (!prev[projectId]) return prev
const next = { ...prev }
delete next[projectId]
return next
})
useNotificationStore.getState().fetchUnreadCount()
})
.catch(() => {})
}
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 (
,
onClick: () => setModalVisible(true),
},
] : []}
search={{
placeholder: '搜索项目或文件...',
value: searchKeyword,
onChange: handleSearchChange,
onSearch: handleSearch,
}}
showRefresh
onRefresh={fetchProjects}
/>
{/* 搜索结果 */}
{hasSearched && searchResults.length > 0 && (
找到 {searchResults.length} 个结果
{searchResults.map((item, index) => (
handleSearchResultClick(item)}
>
{item.type === 'project' ? (
) : (
)}
{item.type === 'project' ? item.project_name : item.file_name}
{item.match_type}
{item.type === 'project' && (
{item.project_description || '暂无描述'}
)}
{item.type === 'file' && (
项目: {item.project_name}
路径: {item.file_path}
)}
))}
)}
{/* 正常项目列表 */}
{!hasSearched && (
<>
{paginatedProjects.map((project) => (
handleOpenProject(project.id)}
actions={type === 'my' ? [
handleEdit(e, project)} />,
handleGitSettings(e, project)} />,
handleKnowledge(e, project)} />,
handleMembers(e, project)} />,
] : [
,
]}
>
{/* 公开项目标识(仅我的项目显示,参与项目不显示) */}
{type === 'my' && project.is_public === 1 && (
公开
)}
{/* 参与项目:右上角显示未读更新通知数(打开项目后已读) */}
{type === 'share' && (unreadByProject[project.id] || 0) > 0 && (
{unreadByProject[project.id] > 99 ? '99+' : unreadByProject[project.id]}
)}
{/* 统一的卡片骨架:标题 + 描述(主要信息)+ 底部统计/归属/角色 */}
{type === 'my' ? : }
{project.name}
{project.description || '暂无描述'}
{/* 归属人信息 + 项目信息:单行展示 */}
{type === 'my' ? (
<>
{project.doc_count || 0} 文档
{formatRelativeTime(project.last_activity_at) || formatDate(project.created_at) || '—'}
{project.member_count ?? 0} 人参与
>
) : (
<>
{project.doc_count || 0} 文档
{cardOwnerInitial(project)}
{cardOwnerName(project)}
{cardRoleIcon(project)}
{cardRoleLabel(project)}
>
)}
))}
{filteredProjects.length === 0 && !loading && (
)}
{filteredProjects.length > 0 && (
{
if (type === 'page') {
return
}
return originalElement
}}
/>
)}
>
)}
{/* 搜索无结果提示 */}
{hasSearched && !searching && searchResults.length === 0 && (
)}
{
setModalVisible(false)
form.resetFields()
}}
footer={null}
>
{
setEditModalVisible(false)
editForm.resetFields()
}}
footer={null}
>
{currentProject?.is_public !== 1 ? (
开启“公开项目”后,可以生成项目分享链接和访问密码。
) : shareInfo ? (
<>
: null
}
/>
访问密码保护
{hasPassword && (
setPassword(e.target.value)}
/>
)}
>
) : null}
}
onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
handleDeleteProject(currentProject.id)
}}
>
删除项目
}
onClick={handleOpenTransfer}
>
转移所有权
{
setTransferModalVisible(false)
transferForm.resetFields()
}}
onOk={() => transferForm.submit()}
confirmLoading={loadingMembers}
>
{
setMembersModalVisible(false)
memberForm.resetFields()
setMembers([])
setUsers([])
}}
footer={null}
width={700}
>
{users.length === 0 ? (
{loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'}
) : null}
当前成员
{members.length === 0 ? (
) : (
{
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 (
)
},
},
]}
/>
)}
{
setGitModalVisible(false)
setGitRepos([])
}}
footer={null}
width={800}
>
} onClick={handleAddRepo}>
添加仓库
(
{text}
{record.is_default === 1 && 默认}
)
},
{
title: '仓库地址',
dataIndex: 'repo_url',
key: 'repo_url',
ellipsis: true,
},
{
title: '分支',
dataIndex: 'branch',
key: 'branch',
width: 100,
},
{
title: '操作',
key: 'action',
width: 150,
render: (_, record) => (
} onClick={() => handleEditRepo(record)}>编辑
} onClick={() => handleDeleteRepo(record.id)}>删除
),
},
]}
/>
{
setGitRepoModalVisible(false)
repoForm.resetFields()
}}
footer={null}
>
} />
{/* 知识库向量化弹窗 */}
} onClick={() => refreshProgress(currentProject?.id)} disabled={vectorizing || loadingProgress}>
刷新
,
,
,
,
]}
>
{loadingProgress && !progress ? (
加载向量化进度...
) : !progress ? (
) : (
{!progress.embedding_ready && (
尚未配置可用的向量模型(Embedding),请先到「模型配置 - 向量模型」中添加并启用,再进行向量化。
)}
{currentTask && (
)}
)}
)
}
export default ProjectList