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

761 lines
25 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'
2025-12-29 12:53:50 +00:00
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember } from '@/api/project'
2025-12-20 11:18:59 +00:00
import { getProjectShareInfo, updateShareSettings } 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)
2025-12-20 11:18:59 +00:00
const [shareModalVisible, setShareModalVisible] = 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()
const [memberForm] = Form.useForm()
2025-12-20 11:18:59 +00:00
const navigate = useNavigate()
useEffect(() => {
fetchProjects()
2025-12-29 12:53:50 +00:00
}, [type])
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,
})
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('项目更新失败')
}
}
2025-12-20 11:18:59 +00:00
const handleOpenProject = (projectId) => {
navigate(`/projects/${projectId}/docs`)
}
// 打开分享设置
const handleShare = async (e, project) => {
e.stopPropagation()
setCurrentProject(project)
try {
const res = await getProjectShareInfo(project.id)
setShareInfo(res.data)
setHasPassword(res.data.has_password)
setPassword('')
setShareModalVisible(true)
} catch (error) {
console.error('Get share info error:', error)
message.error('获取分享信息失败')
}
}
// 复制分享链接
const handleCopyLink = () => {
if (!shareInfo) return
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
navigator.clipboard.writeText(fullUrl)
message.success('分享链接已复制')
}
// 切换密码保护
const handlePasswordToggle = async (checked) => {
if (!checked) {
// 取消密码
try {
await updateShareSettings(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 updateShareSettings(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('设置密码失败')
}
}
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') {
// 跳转到文件
navigate(`/projects/${item.project_id}/docs?file=${encodeURIComponent(item.file_path)}`)
}
}
// 过滤项目(仅在未使用全局搜索时进行本地过滤)
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
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 && (
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
2025-12-20 11:18:59 +00:00
{filteredProjects.map((project) => (
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
<Card
hoverable
className="project-card"
onClick={() => handleOpenProject(project.id)}
2025-12-29 12:53:50 +00:00
actions={type === 'my' ? [
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
2025-12-20 11:18:59 +00:00
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
2025-12-29 12:53:50 +00:00
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [
2025-12-20 11:18:59 +00:00
<EyeOutlined key="view" />,
2025-12-29 12:53:50 +00:00
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
2025-12-20 11:18:59 +00:00
]}
>
2025-12-29 12:53:50 +00:00
{/* 公开项目标识 */}
{project.is_public === 1 && (
<div className="project-card-public-badge">公开</div>
)}
2025-12-20 11:18:59 +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.visit_count}</span>
2025-12-29 12:53:50 +00:00
{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>
)}
2025-12-20 11:18:59 +00:00
</div>
</Card>
</Col>
))}
2025-12-29 12:53:50 +00:00
{filteredProjects.length === 0 && !loading && (
2025-12-20 11:18:59 +00:00
<Col span={24}>
2025-12-29 12:53:50 +00:00
<Empty description={type === 'my' ? "还没有项目,创建一个开始吧" : "还没有参与的项目"} />
2025-12-20 11:18:59 +00:00
</Col>
)}
</Row>
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>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
handleDeleteProject(currentProject.id)
}}
>
删除项目
</Button>
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>
<Modal
title="分享设置"
open={shareModalVisible}
onCancel={() => setShareModalVisible(false)}
footer={null}
width={500}
>
{shareInfo && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>链接</label>
<Input
value={`${window.location.origin}${shareInfo.share_url}`}
readOnly
addonAfter={
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
}
2025-12-29 12:53:50 +00:00
/>
2026-01-01 14:41:10 +00:00
</div>
2025-12-29 12:53:50 +00:00
2026-01-01 14:41:10 +00:00
{/* 只有在我的项目中才显示密码设置功能 */}
{type === 'my' && (
<>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
2025-12-20 11:18:59 +00:00
2026-01-01 14:41:10 +00:00
{hasPassword && (
2025-12-29 12:53:50 +00:00
<div>
2026-01-01 14:41:10 +00:00
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
2025-12-29 12:53:50 +00:00
</div>
2026-01-01 14:41:10 +00:00
)}
</>
)}
2025-12-20 11:18:59 +00:00
2026-01-01 14:41:10 +00:00
{/* 参与项目显示提示 */}
{type === 'share' && shareInfo.has_password && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
该项目已设置访问密码保护
</div>
)}
</Space>
)}
</Modal>
<Modal
title="成员管理"
open={membersModalVisible}
onCancel={() => {
setMembersModalVisible(false)
memberForm.resetFields()
setMembers([])
setUsers([])
}}
footer={null}
width={700}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div>
{users.length === 0 ? (
<div style={{ marginBottom: 16, color: '#999' }}>
{loadingMembers ? '正在加载用户列表...' : '没有可添加的用户'}
</div>) : null}
<Form
form={memberForm}
layout="inline"
onFinish={handleAddMember}
>
<Form.Item
name="user_id"
rules={[{ required: true, message: '请选择用户' }]}
style={{ width: 250 }}
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>
</div>
2025-12-20 11:18:59 +00:00
)
}
export default ProjectList