nex_docus/frontend/src/pages/ProjectList/ProjectList.jsx

1408 lines
49 KiB
React
Raw Normal View History

2025-12-20 11:18:59 +00:00
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
2026-08-05 14:18:25 +00:00
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd'
2026-08-17 16:55:44 +00:00
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } 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'
2026-08-18 16:58:02 +00:00
import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification'
import useNotificationStore from '@/stores/notificationStore'
2025-12-20 11:18:59 +00:00
import ListActionBar from '@/components/ListActionBar/ListActionBar'
import Toast from '@/components/Toast/Toast'
2026-07-24 07:37:22 +00:00
import useProjectKnowledge from './useProjectKnowledge'
2026-08-17 16:55:44 +00:00
import useUserStore from '@/stores/userStore'
2025-12-20 11:18:59 +00:00
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-08-17 16:55:44 +00:00
const { user: currentUser } = useUserStore()
2026-02-11 02:26:50 +00:00
const [currentPage, setCurrentPage] = useState(1)
2026-08-18 16:58:02 +00:00
const [unreadByProject, setUnreadByProject] = useState({})
2026-02-11 02:26:50 +00:00
const pageSize = 8
2025-12-20 11:18:59 +00:00
2026-08-17 16:55:44 +00:00
// 参与项目角色 -> 显示名称
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 ''
}
}
2026-08-18 16:58:02 +00:00
// 相对时间(用于“最后更新”)
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)
}
2026-08-17 16:55:44 +00:00
// 卡片归属用户显示名(我的项目 -> 当前用户;参与项目 -> 项目所有者)
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 <CrownOutlined />
if (project.user_role === 'editor') return <EditOutlined />
return <EyeOutlined />
}
2026-07-24 07:37:22 +00:00
const {
kbModalVisible,
progress,
currentTask,
loadingProgress,
vectorizing,
openKnowledgeModal,
closeKnowledgeModal,
2026-08-05 14:18:25 +00:00
refreshProgress,
2026-07-24 07:37:22 +00:00
runVectorize,
} = useProjectKnowledge()
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 || [])
2026-08-18 16:58:02 +00:00
// 参与项目:加载每个项目的未读更新通知数(与消息通知保持一致)
if (type === 'share') {
getUnreadByProject()
.then((unreadRes) => setUnreadByProject(unreadRes.data?.unread_by_project || {}))
.catch(() => setUnreadByProject({}))
} else {
setUnreadByProject({})
}
2025-12-20 11:18:59 +00:00
} 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,
})
2026-06-03 09:08:06 +00:00
setShareInfo(null)
2026-05-09 02:45:30 +00:00
setHasPassword(false)
setPassword('')
2025-12-29 12:53:50 +00:00
setEditModalVisible(true)
}
2026-06-03 09:08:06 +00:00
// 切换公开/私有(立即生效)
const handlePublicToggle = async (checked) => {
if (!currentProject) return
2025-12-29 12:53:50 +00:00
try {
2026-05-15 11:59:49 +00:00
const res = await updateProject(currentProject.id, {
2026-06-03 09:08:06 +00:00
is_public: checked ? 1 : 0,
2025-12-29 12:53:50 +00:00
})
2026-05-15 11:59:49 +00:00
const updatedProject = res.data || {
...currentProject,
2026-06-03 09:08:06 +00:00
is_public: checked ? 1 : 0,
2026-05-15 11:59:49 +00:00
}
setCurrentProject(updatedProject)
2026-06-03 09:08:06 +00:00
message.success(checked ? '项目已设为公开' : '项目已设为私有')
2026-05-15 11:59:49 +00:00
await fetchProjects()
2026-06-03 09:08:06 +00:00
if (checked) {
2026-05-15 11:59:49 +00:00
const shareRes = await getProjectShareInfo(currentProject.id)
setShareInfo(shareRes.data)
setHasPassword(shareRes.data.has_password)
setPassword(shareRes.data.access_pass || '')
2026-06-03 09:08:06 +00:00
} else {
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
setHasPassword(false)
setPassword('')
2026-05-15 11:59:49 +00:00
}
2026-06-03 09:08:06 +00:00
} catch (error) {
console.error('Toggle public error:', error)
message.error('操作失败')
}
}
2026-05-15 11:59:49 +00:00
2026-06-03 09:08:06 +00:00
// 更新项目(仅名称和描述)
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()
2025-12-29 12:53:50 +00:00
setEditModalVisible(false)
editForm.resetFields()
} catch (error) {
console.error('Update project error:', error)
message.error('项目更新失败')
}
}
2026-05-09 02:45:30 +00:00
useEffect(() => {
if (!editModalVisible || !currentProject) return
if (currentProject.is_public !== 1) {
2026-06-03 09:08:06 +00:00
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
2026-05-09 02:45:30 +00:00
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) {
2026-06-03 09:08:06 +00:00
console.error('Get project share info error:', error)
2026-05-09 02:45:30 +00:00
}
})()
2026-06-03 09:08:06 +00:00
}, [editModalVisible, currentProject])
2026-05-09 02:45:30 +00:00
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)
}
2026-07-24 07:37:22 +00:00
// 打开知识库(向量化进度)弹窗
const handleKnowledge = (e, project) => {
e.stopPropagation()
setCurrentProject(project)
openKnowledgeModal(project)
}
2026-01-05 10:50:29 +00:00
// 加载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) => {
2026-08-18 16:58:02 +00:00
// 打开参与项目时,将其未读更新通知标记为已读(与消息通知联动)
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(() => {})
}
2025-12-20 11:18:59 +00:00
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 }}>
2026-08-05 14:18:25 +00:00
<div style={{ marginBottom: 8, color: 'var(--text-color-secondary)' }}>
2026-01-01 14:41:10 +00:00
找到 {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' && (
2026-08-05 14:18:25 +00:00
<div style={{ fontSize: 12, color: 'var(--text-color-secondary)' }}>
2026-01-01 14:41:10 +00:00
<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
2026-08-17 16:55:44 +00:00
className={type === 'share' ? 'project-card project-card-share' : 'project-card project-card-my'}
2026-02-11 02:26:50 +00:00
onClick={() => handleOpenProject(project.id)}
actions={type === 'my' ? [
2026-08-05 14:18:25 +00:00
<Tooltip key="settings" title="项目设置"><SettingOutlined onClick={(e) => handleEdit(e, project)} /></Tooltip>,
<Tooltip key="git" title="Git 仓库"><GithubOutlined onClick={(e) => handleGitSettings(e, project)} /></Tooltip>,
<Tooltip key="kb" title="知识库向量化"><DatabaseOutlined onClick={(e) => handleKnowledge(e, project)} /></Tooltip>,
<Tooltip key="members" title="成员管理"><TeamOutlined onClick={(e) => handleMembers(e, project)} /></Tooltip>,
2026-02-11 02:26:50 +00:00
] : [
2026-08-17 16:55:44 +00:00
<Tooltip key="view" title="进入项目"><EyeOutlined /></Tooltip>,
2026-02-11 02:26:50 +00:00
]}
>
2026-08-18 16:58:02 +00:00
{/* 公开项目标识(仅我的项目显示,参与项目不显示) */}
{type === 'my' && project.is_public === 1 && (
2026-02-11 02:26:50 +00:00
<div className="project-card-public-badge">公开</div>
2025-12-29 12:53:50 +00:00
)}
2026-08-18 16:58:02 +00:00
{/* 参与项目:右上角显示未读更新通知数(打开项目后已读) */}
{type === 'share' && (unreadByProject[project.id] || 0) > 0 && (
<div
className="project-card-update-badge"
title={`${unreadByProject[project.id]} 条项目更新通知`}
>
{unreadByProject[project.id] > 99 ? '99+' : unreadByProject[project.id]}
</div>
)}
2026-08-17 16:55:44 +00:00
{/* 统一的卡片骨架:标题 + 描述(主要信息)+ 底部统计/归属/角色 */}
<div className="project-card-body">
<div className="project-card-title-row">
<span className={`project-card-title-icon ${type === 'my' ? 'is-owner' : 'is-shared'}`}>
{type === 'my' ? <FolderOutlined /> : <TeamOutlined />}
2026-02-11 02:26:50 +00:00
</span>
2026-08-17 16:55:44 +00:00
<h3 title={project.name}>{project.name}</h3>
</div>
<p className="project-description">{project.description || '暂无描述'}</p>
{/* 归属人信息 + 项目信息:单行展示 */}
<div className="project-card-meta">
{type === 'my' ? (
<>
<span className="project-stat-item" title="文档数量">
<FileTextOutlined /> {project.doc_count || 0} 文档
</span>
2026-08-18 16:58:02 +00:00
<span className="project-stat-item" title="最后更新时间">
<CalendarOutlined /> {formatRelativeTime(project.last_activity_at) || formatDate(project.created_at) || '—'}
2026-08-17 16:55:44 +00:00
</span>
<span className="project-stat-item" title="项目参与人数量">
<TeamOutlined /> {project.member_count ?? 0} 人参与
</span>
</>
) : (
<>
<span className="project-stat-item" title="文档数量">
<FileTextOutlined /> {project.doc_count || 0} 文档
</span>
<span className="project-card-owner" title={cardOwnerName(project)}>
<span className="project-card-owner-avatar">{cardOwnerInitial(project)}</span>
<span className="project-card-owner-name">{cardOwnerName(project)}</span>
</span>
<span className={`project-card-role-badge ${cardRoleClass(project)}`} title={cardRoleLabel(project)}>
{cardRoleIcon(project)}
{cardRoleLabel(project)}
</span>
</>
)}
</div>
2026-02-11 02:26:50 +00:00
</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>
2026-06-03 09:08:06 +00:00
<Form.Item label="公开项目">
<Switch
checked={currentProject?.is_public === 1}
onChange={handlePublicToggle}
/>
2026-01-01 14:41:10 +00:00
</Form.Item>
2026-05-09 02:45:30 +00:00
<Form.Item label="项目公开分享">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
2026-06-03 09:08:06 +00:00
{currentProject?.is_public !== 1 ? (
2026-08-05 14:18:25 +00:00
<div style={{ color: 'var(--text-color-secondary)', lineHeight: 1.7 }}>
2026-06-03 09:08:06 +00:00
开启公开项目可以生成项目分享链接和访问密码
2026-05-09 02:45:30 +00:00
</div>
2026-06-03 09:08:06 +00:00
) : shareInfo ? (
2026-05-09 02:45:30 +00:00
<>
<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 && (
2026-06-03 09:08:06 +00:00
<Space.Compact style={{ width: '100%' }}>
2026-05-09 02:45:30 +00:00
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
2026-06-03 09:08:06 +00:00
<Button type="primary" onClick={handleSavePassword}>
2026-05-09 02:45:30 +00:00
保存密码
</Button>
2026-06-03 09:08:06 +00:00
</Space.Compact>
2026-05-09 02:45:30 +00:00
)}
</>
2026-06-03 09:08:06 +00:00
) : null}
2026-05-09 02:45:30 +00:00
</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 ? (
2026-08-05 14:18:25 +00:00
<div style={{ marginBottom: 16, color: 'var(--text-color-secondary)' }}>
2026-01-01 14:41:10 +00:00
{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-07-24 07:37:22 +00:00
{/* 知识库向量化弹窗 */}
<Modal
title={`知识库 - ${currentProject?.name || ''}`}
open={kbModalVisible}
onCancel={closeKnowledgeModal}
width={640}
footer={[
2026-08-05 14:18:25 +00:00
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshProgress(currentProject?.id)} disabled={vectorizing || loadingProgress}>
刷新
</Button>,
2026-07-24 07:37:22 +00:00
<Button key="close" onClick={closeKnowledgeModal}>关闭</Button>,
<Button
key="incremental"
type="primary"
loading={vectorizing}
disabled={vectorizing || !progress?.embedding_ready}
onClick={() => runVectorize(currentProject?.id, { force: false })}
>
增量向量化
</Button>,
<Button
key="full"
danger
loading={vectorizing}
disabled={vectorizing || !progress?.embedding_ready}
onClick={() => runVectorize(currentProject?.id, { force: true })}
>
全量向量化
</Button>,
]}
>
{loadingProgress && !progress ? (
2026-08-05 14:18:25 +00:00
<div style={{ textAlign: 'center', padding: '32px 0', color: 'var(--text-color-secondary)' }}>
2026-07-24 07:37:22 +00:00
加载向量化进度...
</div>
) : !progress ? (
<Empty description="暂无数据" />
) : (
<div>
{!progress.embedding_ready && (
2026-08-05 14:18:25 +00:00
<div className="kb-warning-banner">
2026-07-24 07:37:22 +00:00
尚未配置可用的向量模型Embedding请先到模型配置 - 向量模型中添加并启用再进行向量化
</div>
)}
{currentTask && (
<Alert
style={{ marginBottom: 16 }}
type={currentTask.status === 'failed' ? 'error' : currentTask.status === 'success' ? 'success' : 'info'}
showIcon
message={
`最近任务:${currentTask.task_type === 'full' ? '全量向量化' : '增量向量化'} · ` +
`${currentTask.status === 'pending' ? '等待执行' : currentTask.status === 'running' ? '后台执行中' : currentTask.status === 'success' ? '已完成' : '执行失败'}`
}
description={
currentTask.status === 'failed'
? (currentTask.error_message || '任务执行失败')
: `处理 ${currentTask.processed || 0} 个,跳过 ${currentTask.skipped || 0} 个,失败 ${currentTask.failed || 0}`
}
/>
)}
<Progress
percent={progress.percent}
status={progress.failed > 0 ? 'exception' : (progress.pending > 0 ? 'active' : 'success')}
/>
<Row gutter={16} style={{ marginTop: 16, textAlign: 'center' }}>
<Col span={6}>
<div style={{ fontSize: 22, fontWeight: 600 }}>{progress.total}</div>
2026-08-05 14:18:25 +00:00
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>MD 文件总数</div>
2026-07-24 07:37:22 +00:00
</Col>
<Col span={6}>
<div style={{ fontSize: 22, fontWeight: 600, color: '#52c41a' }}>{progress.success}</div>
2026-08-05 14:18:25 +00:00
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>已向量化</div>
2026-07-24 07:37:22 +00:00
</Col>
<Col span={6}>
<div style={{ fontSize: 22, fontWeight: 600, color: '#faad14' }}>{progress.pending}</div>
2026-08-05 14:18:25 +00:00
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>待处理</div>
2026-07-24 07:37:22 +00:00
</Col>
<Col span={6}>
<div style={{ fontSize: 22, fontWeight: 600, color: '#ff4d4f' }}>{progress.failed}</div>
2026-08-05 14:18:25 +00:00
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>失败</div>
2026-07-24 07:37:22 +00:00
</Col>
</Row>
{progress.failed_items?.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}>失败明细</div>
<div style={{ maxHeight: 160, overflow: 'auto' }}>
{progress.failed_items.map((item) => (
2026-08-05 14:18:25 +00:00
<div key={item.file_path} style={{ padding: '6px 0', borderBottom: '1px solid var(--border-color)', fontSize: 13 }}>
2026-07-24 07:37:22 +00:00
<div style={{ wordBreak: 'break-all' }}>{item.file_path}</div>
2026-08-05 14:18:25 +00:00
<div style={{ color: 'var(--text-color-secondary)', fontSize: 12 }}>{item.error}</div>
2026-07-24 07:37:22 +00:00
</div>
))}
</div>
</div>
)}
{progress.pending_items?.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ marginBottom: 8, fontWeight: 600, color: '#faad14' }}>待处理文件</div>
<div style={{ maxHeight: 140, overflow: 'auto' }}>
{progress.pending_items.map((path) => (
2026-08-05 14:18:25 +00:00
<div key={path} style={{ padding: '4px 0', fontSize: 13, wordBreak: 'break-all', color: 'var(--text-color-secondary)' }}>
2026-07-24 07:37:22 +00:00
{path}
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
2026-01-01 14:41:10 +00:00
</div>
2025-12-20 11:18:59 +00:00
)
}
export default ProjectList