import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd' import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, BranchesOutlined, GithubOutlined, GithubFilled, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons' import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, testGitRepoConnection, transferProject } from '@/api/project' import { 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) { Toast.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) Toast.success('项目所有权已转移') setTransferModalVisible(false) setEditModalVisible(false) transferForm.resetFields() fetchProjects() } catch (error) { console.error('Transfer error:', error) Toast.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) Toast.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) Toast.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) Toast.success('项目更新成功') await fetchProjects() setEditModalVisible(false) editForm.resetFields() } catch (error) { console.error('Update project error:', error) Toast.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 [testingGitRepo, setTestingGitRepo] = useState(false) 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) Toast.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 handleTestRepoConnection = async () => { try { const values = await repoForm.validateFields(['repo_url', 'branch', 'username', 'token']) setTestingGitRepo(true) await testGitRepoConnection(currentProject.id, { ...values, repo_id: editingRepo?.id, }) Toast.success('远端仓库认证成功') } catch (error) { if (error?.errorFields) return const errorMsg = error.response?.data?.detail || '远端仓库认证失败' Toast.error(errorMsg) } finally { setTestingGitRepo(false) } } // 删除仓库 const handleDeleteRepo = (repoId) => { Modal.confirm({ title: '确认删除', content: '确定要删除这个Git仓库配置吗?', onOk: async () => { try { await deleteGitRepo(currentProject.id, repoId) Toast.success('删除成功') fetchGitRepos(currentProject.id) } catch (error) { console.error('Delete repo error:', error) Toast.error(error.response?.data?.detail || '删除失败') } }, }) } // 保存仓库(新增/更新) const handleSaveRepo = async (values) => { try { const data = { ...values, is_default: values.is_default ? 1 : 0, } if (editingRepo) { await updateGitRepo(currentProject.id, editingRepo.id, data) Toast.success('更新成功') } else { await createGitRepo(currentProject.id, data) Toast.success('添加成功') } setGitRepoModalVisible(false) fetchGitRepos(currentProject.id) } catch (error) { console.error('Save repo error:', error) Toast.error(error.response?.data?.detail || (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('') Toast.success('已取消访问密码') const res = await getProjectShareInfo(currentProject.id) setShareInfo(res.data) } catch (error) { console.error('Update settings error:', error) Toast.error('操作失败') } } else { setHasPassword(true) } } // 保存密码 const handleSavePassword = async () => { if (!password.trim()) { Toast.warning('请输入访问密码') return } try { await updateProjectShareSettings(currentProject.id, { access_pass: password }) Toast.success('访问密码已设置') const res = await getProjectShareInfo(currentProject.id) setShareInfo(res.data) setHasPassword(true) } catch (error) { console.error('Save password error:', error) Toast.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: "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) Toast.error('获取数据失败: ' + (error.response?.data?.detail || error.message)) } finally { setLoadingMembers(false) } } // 添加成员 const handleAddMember = async (values) => { try { await addProjectMember(currentProject.id, values) Toast.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 || '添加成员失败' Toast.error(errorMsg) } } // 删除成员 const handleRemoveMember = async (userId) => { Modal.confirm({ title: '确认删除', content: '确定要删除这个成员吗?', onOk: async () => { try { await removeProjectMember(currentProject.id, userId) Toast.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 || '删除成员失败' Toast.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)} />, 0 ? 'Git 仓库(已配置)' : 'Git 仓库'}> {(project.git_repo_count || 0) > 0 ? ( handleGitSettings(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}
{ setTransferModalVisible(false) transferForm.resetFields() }} onOk={() => transferForm.submit()} confirmLoading={loadingMembers} >

警告:转移所有权后,您将失去该项目的所有权并变为普通管理员,且无法撤销此操作。

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 ? "加载中..." : "没有找到用户"} />

当前成员

{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} >
( {text} {record.is_default === 1 && 默认} ) }, { title: '仓库地址', dataIndex: 'repo_url', key: 'repo_url', ellipsis: true, }, { title: '分支', dataIndex: 'branch', key: 'branch', width: 100, }, { title: '同步范围', dataIndex: 'sync_path', key: 'sync_path', width: 150, render: (v) => ( v ? {v} : 整个仓库 ), }, { title: '操作', key: 'action', width: 150, render: (_, record) => ( ), }, ]} /> { setGitRepoModalVisible(false) repoForm.resetFields() }} footer={null} >
} />
{/* 知识库向量化弹窗 */} } onClick={() => refreshProgress(currentProject?.id)} disabled={vectorizing || loadingProgress}> 刷新 , , , , ]} > {loadingProgress && !progress ? (
加载向量化进度...
) : !progress ? ( ) : (
{!progress.embedding_ready && (
尚未配置可用的向量模型(Embedding),请先到「模型配置 - 向量模型」中添加并启用,再进行向量化。
)} {currentTask && ( )} 0 ? 'exception' : (progress.pending > 0 ? 'active' : 'success')} />
{progress.total}
MD 文件总数
{progress.success}
已向量化
{progress.pending}
待处理
{progress.failed}
失败
{progress.failed_items?.length > 0 && (
失败明细
{progress.failed_items.map((item) => (
{item.file_path}
{item.error}
))}
)} {progress.pending_items?.length > 0 && (
待处理文件
{progress.pending_items.map((path) => (
{path}
))}
)} )} ) } export default ProjectList