增加了项目转移
parent
b9f1b49572
commit
e2211fbc9b
|
|
@ -1,9 +1,17 @@
|
|||
## Stage 1: Add Back Icon to Project Detail View
|
||||
**Goal**: Provide a clear way for users to return to the project list or previous page from the Project Browse Mode (DocumentPage).
|
||||
**Success Criteria**: A Close icon is visible in the Sider Header and navigates the user back using history or fallback.
|
||||
**Status**: Complete
|
||||
## Stage 1: Auto-append .md Extension
|
||||
**Goal**: Ensure new files created in the editor default to .md if no extension is provided.
|
||||
**Files**: `frontend/src/pages/Document/DocumentEditor.jsx`
|
||||
**Status**: In Progress
|
||||
|
||||
## Stage 2: Refine Navigation Logic
|
||||
**Goal**: Ensure the back button handles entries from notifications or direct links correctly.
|
||||
**Success Criteria**: Uses browser history if available, otherwise defaults to project list.
|
||||
**Status**: Complete
|
||||
## Stage 2: Project Ownership Transfer
|
||||
**Goal**: Allow project owners to transfer ownership to another user.
|
||||
**Files**:
|
||||
- `backend/app/api/v1/projects.py` (Add transfer API)
|
||||
- `frontend/src/api/project.js` (Add frontend API method)
|
||||
- `frontend/src/pages/ProjectList/ProjectList.jsx` (Add Transfer UI)
|
||||
**Status**: Not Started
|
||||
|
||||
## Stage 3: Specific File Share Link
|
||||
**Goal**: Add a context menu option to share specific files if the project is shared.
|
||||
**Files**: `frontend/src/pages/Document/DocumentEditor.jsx`
|
||||
**Status**: Not Started
|
||||
|
|
@ -21,6 +21,7 @@ from app.schemas.project import (
|
|||
ProjectMemberResponse,
|
||||
ProjectShareSettings,
|
||||
ProjectShareInfo,
|
||||
ProjectTransfer,
|
||||
)
|
||||
from app.schemas.response import success_response
|
||||
from app.services.storage import storage_service
|
||||
|
|
@ -263,6 +264,93 @@ async def update_project(
|
|||
return success_response(data=project_data.dict(), message="项目更新成功")
|
||||
|
||||
|
||||
@router.post("/{project_id}/transfer", response_model=dict)
|
||||
async def transfer_project(
|
||||
project_id: int,
|
||||
transfer_in: ProjectTransfer,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""转移项目所有权"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 只有项目所有者可以转移
|
||||
if project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="只有项目所有者可以转移项目")
|
||||
|
||||
# 检查新所有者是否存在
|
||||
user_result = await db.execute(select(User).where(User.id == transfer_in.new_owner_id))
|
||||
new_owner = user_result.scalar_one_or_none()
|
||||
|
||||
if not new_owner:
|
||||
raise HTTPException(status_code=404, detail="目标用户不存在")
|
||||
|
||||
if new_owner.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="不能转移给自己")
|
||||
|
||||
# 1. 如果新所有者已经是成员,删除成员记录
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == new_owner.id
|
||||
)
|
||||
)
|
||||
existing_member = member_result.scalar_one_or_none()
|
||||
if existing_member:
|
||||
await db.delete(existing_member)
|
||||
|
||||
# 2. 将旧所有者添加为管理员成员
|
||||
# 检查旧所有者是否已经在member表中(理论上owner不在member表中,但为了健壮性检查一下)
|
||||
old_member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
if not old_member_result.scalar_one_or_none():
|
||||
old_owner_member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=current_user.id,
|
||||
role="admin",
|
||||
invited_by=current_user.id
|
||||
)
|
||||
db.add(old_owner_member)
|
||||
|
||||
# 3. 更新项目所有者
|
||||
project.owner_id = new_owner.id
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 记录日志
|
||||
await log_service.log_project_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.UPDATE_PROJECT,
|
||||
project_id=project_id,
|
||||
user=current_user,
|
||||
detail={"action": "transfer_ownership", "new_owner": new_owner.username},
|
||||
request=request,
|
||||
)
|
||||
|
||||
# 发送通知
|
||||
await notification_service.create_notification(
|
||||
db=db,
|
||||
user_id=new_owner.id,
|
||||
title="项目所有权转让",
|
||||
content=f"用户 {current_user.nickname or current_user.username} 将项目 [{project.name}] 的所有权转让给了您。",
|
||||
category="project",
|
||||
link=f"/projects/{project_id}/docs",
|
||||
type="info"
|
||||
)
|
||||
|
||||
return success_response(message="项目所有权转移成功")
|
||||
|
||||
|
||||
@router.delete("/{project_id}", response_model=dict)
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
|
|
|
|||
|
|
@ -90,3 +90,8 @@ class ProjectShareInfo(BaseModel):
|
|||
share_url: str = Field(..., description="分享链接")
|
||||
has_password: bool = Field(..., description="是否设置了访问密码")
|
||||
access_pass: Optional[str] = Field(None, description="访问密码(仅项目所有者可见)")
|
||||
|
||||
|
||||
class ProjectTransfer(BaseModel):
|
||||
"""转移项目所有权 Schema"""
|
||||
new_owner_id: int = Field(..., description="新所有者ID")
|
||||
|
|
@ -75,6 +75,17 @@ export function deleteProject(projectId) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 转移项目所有权
|
||||
*/
|
||||
export function transferProject(projectId, newOwnerId) {
|
||||
return request({
|
||||
url: `/projects/${projectId}/transfer`,
|
||||
method: 'post',
|
||||
data: { new_owner_id: newOwnerId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目成员
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -343,15 +343,21 @@ function DocumentEditor() {
|
|||
new_path: newPath,
|
||||
}
|
||||
} else {
|
||||
// 自动补全后缀 (仅针对新建文件)
|
||||
let finalName = newName
|
||||
if (operationType === 'create_file' && finalName.indexOf('.') === -1) {
|
||||
finalName += '.md'
|
||||
}
|
||||
|
||||
// 创建操作 - 使用预先计算的父目录路径
|
||||
path = creationParentPath ? `${creationParentPath}/${newName}` : newName
|
||||
path = creationParentPath ? `${creationParentPath}/${finalName}` : finalName
|
||||
|
||||
// 检查文件是否已存在
|
||||
const fileExists = checkFileExists(path)
|
||||
if (fileExists) {
|
||||
Modal.confirm({
|
||||
title: '文件已存在',
|
||||
content: `文件 "${newName}" 已存在,是否覆盖?`,
|
||||
content: `文件 "${finalName}" 已存在,是否覆盖?`,
|
||||
okText: '覆盖',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
|
|
|
|||
|
|
@ -612,7 +612,10 @@ function DocumentPage() {
|
|||
// 复制分享链接
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareInfo) return
|
||||
const fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||||
let fullUrl = `${window.location.origin}${shareInfo.share_url}`
|
||||
if (selectedFile) {
|
||||
fullUrl += `?file=${encodeURIComponent(selectedFile)}`
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
|
|
@ -931,7 +934,7 @@ function DocumentPage() {
|
|||
{/* 分享模态框 */}
|
||||
{/* ... keeping the modal ... */}
|
||||
<Modal
|
||||
title="分享项目"
|
||||
title="分享"
|
||||
open={shareModalVisible}
|
||||
onCancel={() => setShareModalVisible(false)}
|
||||
footer={null}
|
||||
|
|
@ -940,9 +943,14 @@ function DocumentPage() {
|
|||
{shareInfo && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>分享链接</label>
|
||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
|
||||
{selectedFile ? '当前文件分享链接' : '项目分享链接'}
|
||||
</label>
|
||||
<Input
|
||||
value={`${window.location.origin}${shareInfo.share_url}`}
|
||||
value={selectedFile
|
||||
? `${window.location.origin}${shareInfo.share_url}?file=${encodeURIComponent(selectedFile)}`
|
||||
: `${window.location.origin}${shareInfo.share_url}`
|
||||
}
|
||||
readOnly
|
||||
addonAfter={
|
||||
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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, GithubOutlined, CheckOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo } from '@/api/project'
|
||||
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, ShareAltOutlined, 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, updateShareSettings } from '@/api/share'
|
||||
import { getUserList } from '@/api/users'
|
||||
import { searchDocuments } from '@/api/search'
|
||||
|
|
@ -33,12 +33,54 @@ function ProjectList({ type = 'my' }) {
|
|||
const [editForm] = Form.useForm()
|
||||
const [gitForm] = Form.useForm()
|
||||
const [memberForm] = Form.useForm()
|
||||
const [transferModalVisible, setTransferModalVisible] = useState(false)
|
||||
const [transferForm] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
}, [type])
|
||||
|
||||
// ... (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 {
|
||||
|
|
@ -651,6 +693,7 @@ function ProjectList({ type = 'my' }) {
|
|||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
|
|
@ -662,6 +705,14 @@ function ProjectList({ type = 'my' }) {
|
|||
>
|
||||
删除项目
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<SwapOutlined />}
|
||||
onClick={handleOpenTransfer}
|
||||
>
|
||||
转移所有权
|
||||
</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新
|
||||
|
|
@ -678,6 +729,45 @@ function ProjectList({ type = 'my' }) {
|
|||
</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={shareModalVisible}
|
||||
|
|
|
|||
Loading…
Reference in New Issue