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

1145 lines
38 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, Pagination } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
import { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search'
import 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 [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 [currentPage, setCurrentPage] = useState(1)
const pageSize = 8
useEffect(() => {
fetchProjects()
setCurrentPage(1)
}, [type])
useEffect(() => {
setCurrentPage(1)
}, [searchKeyword])
// ... (fetchProjects code)
const handleOpenTransfer = async () => {
setLoadingMembers(true)
setTransferModalVisible(true)
try {
// 获取用户列表 (排除自己)
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
const allUsers = res.data || []
// 过滤掉当前所有者也就是自己虽然API也会校验
setUsers(allUsers.filter(u => u.id !== currentProject.owner_id))
} catch (error) {
message.error('加载用户列表失败')
} finally {
setLoadingMembers(false)
}
}
const handleTransfer = async (values) => {
Modal.confirm({
title: '确认转移',
content: '确定要将项目所有权转移给该用户吗?转移后您将变为管理员,无法再删除项目或转移所有权。',
okText: '确认转移',
okType: 'danger',
onOk: async () => {
try {
await transferProject(currentProject.id, values.new_owner_id)
message.success('项目所有权已转移')
setTransferModalVisible(false)
setEditModalVisible(false)
transferForm.resetFields()
fetchProjects()
} catch (error) {
console.error('Transfer error:', error)
message.error('转移失败: ' + (error.response?.data?.detail || error.message))
}
}
})
}
const fetchProjects = async () => {
setLoading(true)
try {
let res
if (type === 'my') {
res = await getOwnedProjects()
} else if (type === 'share') {
res = await getSharedProjects()
} else {
res = await getMyProjects()
}
setProjects(res.data || [])
} 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,
})
setShareInfo(project.is_public === 1 ? null : { enabled: false, share_url: null, has_password: false, access_pass: null })
setHasPassword(false)
setPassword('')
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('项目更新失败')
}
}
useEffect(() => {
if (!editModalVisible || !currentProject) return
const isPublic = editForm.getFieldValue('is_public')
if (!isPublic) {
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
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, editForm])
const currentEditPublic = Form.useWatch('is_public', editForm)
const isPublicEnablePending = editModalVisible && currentEditPublic && currentProject?.is_public !== 1
useEffect(() => {
if (!editModalVisible || !currentProject) return
if (!currentEditPublic) {
setShareInfo({ enabled: false, share_url: null, has_password: false, access_pass: null })
setHasPassword(false)
setPassword('')
return
}
if (currentProject.is_public !== 1) {
setShareInfo(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('Refresh project share info error:', error)
}
})()
}, [currentEditPublic, editModalVisible, currentProject, editForm])
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)
}
// 加载Git仓库列表
const fetchGitRepos = async (projectId) => {
setLoadingRepos(true)
try {
const res = await getGitRepos(projectId)
setGitRepos(res.data || [])
} catch (error) {
console.error('Fetch git repos error:', error)
message.error('加载Git仓库失败')
} finally {
setLoadingRepos(false)
}
}
// 打开添加仓库弹窗
const handleAddRepo = () => {
setEditingRepo(null)
repoForm.resetFields()
// 如果是第一个仓库,默认设为默认
if (gitRepos.length === 0) {
repoForm.setFieldsValue({ is_default: 1 })
}
setGitRepoModalVisible(true)
}
// 打开编辑仓库弹窗
const handleEditRepo = (repo) => {
setEditingRepo(repo)
repoForm.setFieldsValue({
...repo,
is_default: repo.is_default === 1,
})
setGitRepoModalVisible(true)
}
// 删除仓库
const handleDeleteRepo = (repoId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个Git仓库配置吗',
onOk: async () => {
try {
await deleteGitRepo(currentProject.id, repoId)
message.success('删除成功')
fetchGitRepos(currentProject.id)
} catch (error) {
console.error('Delete repo error:', error)
message.error('删除失败')
}
},
})
}
// 保存仓库(新增/更新)
const handleSaveRepo = async (values) => {
try {
const data = {
...values,
is_default: values.is_default ? 1 : 0,
}
if (editingRepo) {
await updateGitRepo(currentProject.id, editingRepo.id, data)
message.success('更新成功')
} else {
await createGitRepo(currentProject.id, data)
message.success('添加成功')
}
setGitRepoModalVisible(false)
fetchGitRepos(currentProject.id)
} catch (error) {
console.error('Save repo error:', error)
message.error(editingRepo ? '更新失败' : '添加失败')
}
}
const handleOpenProject = (projectId) => {
navigate(`/projects/${projectId}/docs`)
}
// 复制分享链接
const handleCopyLink = async () => {
if (!shareInfo) return
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(fullUrl)
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
// Fallback for non-secure contexts or older browsers
const textArea = document.createElement("textarea")
textArea.value = fullUrl
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
textArea.style.top = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
Toast.success('复制成功', '分享链接已复制到剪贴板')
} else {
Toast.error('复制失败', '请手动复制链接')
}
}
} catch (err) {
console.error('Failed to copy:', err)
Toast.error('复制失败', '无法访问剪贴板')
}
}
// 切换密码保护
const handlePasswordToggle = async (checked) => {
if (!checked) {
try {
await updateProjectShareSettings(currentProject.id, { access_pass: null })
setHasPassword(false)
setPassword('')
message.success('已取消访问密码')
const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data)
} catch (error) {
console.error('Update settings error:', error)
message.error('操作失败')
}
} else {
setHasPassword(true)
}
}
// 保存密码
const handleSavePassword = async () => {
if (!password.trim()) {
message.warning('请输入访问密码')
return
}
try {
await updateProjectShareSettings(currentProject.id, { access_pass: password })
message.success('访问密码已设置')
const res = await getProjectShareInfo(currentProject.id)
setShareInfo(res.data)
setHasPassword(true)
} catch (error) {
console.error('Save password error:', error)
message.error('设置密码失败')
}
}
// 打开成员管理
const handleMembers = async (e, project) => {
e.stopPropagation()
setCurrentProject(project)
setMembersModalVisible(true)
setLoadingMembers(true)
try {
// 并行加载成员列表和用户列表(只获取普通用户 role_id=3
const [membersRes, usersRes] = await Promise.all([
getProjectMembers(project.id),
getUserList({ page: 1, page_size: 100, status: 1, role_id: 3 })
])
console.log('Members Response:', membersRes)
console.log('Users Response:', usersRes)
const membersData = membersRes.data || []
// 后端返回格式: { code: 200, message: "success", data: [...], total, page, page_size }
const usersData = Array.isArray(usersRes.data) ? usersRes.data : []
console.log('Setting members:', membersData)
console.log('Setting users:', usersData)
setMembers(membersData)
setUsers(usersData)
} catch (error) {
console.error('Get members error:', error)
console.error('Error details:', error.response)
message.error('获取数据失败: ' + (error.response?.data?.detail || error.message))
} finally {
setLoadingMembers(false)
}
}
// 添加成员
const handleAddMember = async (values) => {
try {
await addProjectMember(currentProject.id, values)
message.success('成员添加成功')
memberForm.resetFields()
// 刷新成员列表(带用户名信息)
const res = await getProjectMembers(currentProject.id)
setMembers(res.data || [])
} catch (error) {
console.error('Add member error:', error)
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
message.error(errorMsg)
}
}
// 删除成员
const handleRemoveMember = async (userId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个成员吗?',
onOk: async () => {
try {
await removeProjectMember(currentProject.id, userId)
message.success('成员删除成功')
// 刷新成员列表(带用户名信息)
const res = await getProjectMembers(currentProject.id)
setMembers(res.data || [])
} catch (error) {
console.error('Remove member error:', error)
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
message.error(errorMsg)
}
},
})
}
// 处理搜索输入变化
const handleSearchChange = (value) => {
setSearchKeyword(value)
// 如果清空了输入框,重置搜索状态
if (!value || !value.trim()) {
setSearchResults([])
setHasSearched(false)
}
}
// 处理搜索
const handleSearch = async (keyword) => {
setSearchKeyword(keyword)
if (!keyword || !keyword.trim()) {
// 清空搜索,显示所有项目
setSearchResults([])
setSearching(false)
setHasSearched(false)
return
}
setSearching(true)
setHasSearched(true)
try {
const res = await searchDocuments(keyword.trim())
setSearchResults(res.data || [])
} catch (error) {
console.error('Search error:', error)
Toast.error('搜索失败', error.response?.data?.detail || error.message)
} finally {
setSearching(false)
}
}
// 处理搜索结果点击
const handleSearchResultClick = (item) => {
if (item.type === 'project') {
// 跳转到项目文档页
navigate(`/projects/${item.project_id}/docs`)
} else if (item.type === 'file') {
// 跳转到文件
navigate(`/projects/${item.project_id}/docs?file=${encodeURIComponent(item.file_path)}&keyword=${encodeURIComponent(searchKeyword)}`)
}
}
// 过滤项目(仅在未使用全局搜索时进行本地过滤)
const filteredProjects = !hasSearched && searchKeyword
? projects.filter((project) =>
project.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
(project.description && project.description.toLowerCase().includes(searchKeyword.toLowerCase()))
)
: projects
const paginatedProjects = filteredProjects.slice((currentPage - 1) * pageSize, currentPage * pageSize)
return (
<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 }}>
{paginatedProjects.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)} />,
<GithubOutlined key="git" onClick={(e) => handleGitSettings(e, project)} />,
<TeamOutlined key="members" onClick={(e) => handleMembers(e, project)} />,
] : [
<EyeOutlined key="view" />,
]}
>
{/* 公开项目标识 */}
{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.doc_count || 0}</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>
{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>
)}
</>
)}
{/* 搜索无结果提示 */}
{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 label="项目公开分享">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{!editForm.getFieldValue('is_public') ? (
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
开启公开项目才可以生成项目分享链接和配置访问密码
</div>
) : isPublicEnablePending ? (
<div style={{ color: '#8c8c8c', lineHeight: 1.7 }}>
保存项目后将自动生成新的项目分享链接
</div>
) : (
<>
<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 && (
<div>
<Input.Password
placeholder="请输入访问密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button type="primary" onClick={handleSavePassword} style={{ marginTop: 8 }}>
保存密码
</Button>
</div>
)}
</>
)}
</Space>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
handleDeleteProject(currentProject.id)
}}
>
删除项目
</Button>
<Button
danger
icon={<SwapOutlined />}
onClick={handleOpenTransfer}
>
转移所有权
</Button>
</Space>
<Space>
<Button type="primary" htmlType="submit">
更新
</Button>
<Button onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
}}>
取消
</Button>
</Space>
</Space>
</Form.Item>
</Form>
</Modal>
<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>
<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>
<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>
</div>
)
}
export default ProjectList