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

761 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 } 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'
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search'
import ListActionBar from '@/components/ListActionBar/ListActionBar'
import Toast from '@/components/Toast/Toast'
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 [shareModalVisible, setShareModalVisible] = 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 [memberForm] = Form.useForm()
const navigate = useNavigate()
useEffect(() => {
fetchProjects()
}, [type])
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 || [])
} 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,
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('项目更新失败')
}
}
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('设置密码失败')
}
}
// 打开成员管理
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) =>
project.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
(project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase()))
)
: projects
return (
<div className="project-list-container">
<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} 个结果
</div>
<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 }}>
{filteredProjects.map((project) => (
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
<Card
hoverable
className="project-card"
onClick={() => handleOpenProject(project.id)}
actions={type === 'my' ? [
<EditOutlined key="edit" onClick={(e) => handleEdit(e, project)} />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [
<EyeOutlined key="view" />,
<ShareAltOutlined key="share" onClick={(e) => handleShare(e, project)} />,
]}
>
{/* 公开项目标识 */}
{project.is_public === 1 && (
<div className="project-card-public-badge">公开</div>
)}
<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>
{type === 'share' && project.owner_name && (
<span style={{ marginLeft: 12 }}>
所有者: {project.owner_nickname || project.owner_name}
</span>
)}
{type === 'share' && project.user_role && (
<span style={{ marginLeft: 12 }}>
角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'}
</span>
)}
</div>
</Card>
</Col>
))}
{filteredProjects.length === 0 && !loading && (
<Col span={24}>
<Empty description={type === 'my' ? "还没有项目,创建一个开始吧" : "还没有参与的项目"} />
</Col>
)}
</Row>
)}
{/* 搜索无结果提示 */}
{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}
>
<Form.Item
label="项目名称"
name="name"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" />
</Form.Item>
<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>
<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>
<Space>
<Button type="primary" htmlType="submit">
更新
</Button>
<Button onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
}}>
取消
</Button>
</Space>
</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' }} />
}
/>
</div>
{/* 只有在我的项目中才显示密码设置功能 */}
{type === 'my' && (
<>
<div>
<Space>
<span style={{ fontWeight: 500 }}>访问密码保护</span>
<Switch checked={hasPassword} onChange={handlePasswordToggle} />
</Space>
</div>
{hasPassword && (
<div>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="primary"
onClick={handleSavePassword}
style={{ marginTop: 8 }}
>
保存密码
</Button>
</div>
)}
</>
)}
{/* 参与项目显示提示 */}
{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 }}
>
<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}
>
添加
</Button>
</Form.Item>
</Form>
</div>
<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}`
},
},
{
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 (
<Button
type="link"
danger
onClick={() => handleRemoveMember(record.user_id)}
disabled={isOwner}
title={isOwner ? '不能删除项目所有者' : '删除成员'}
>
{isOwner ? '所有者' : '删除'}
</Button>
)
},
},
]}
/>
)}
</div>
</Space>
</Modal>
</div>
)
}
export default ProjectList