增加了项目转移

main
mula.liu 2026-02-09 18:08:38 +08:00
parent b9f1b49572
commit e2211fbc9b
7 changed files with 243 additions and 27 deletions

View File

@ -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

View File

@ -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,

View File

@ -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")

View File

@ -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 },
})
}
/**
* 获取项目成员
*/

View File

@ -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 () => {

View File

@ -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' }} />

View File

@ -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}