增加了项目转移
parent
b9f1b49572
commit
e2211fbc9b
|
|
@ -1,9 +1,17 @@
|
||||||
## Stage 1: Add Back Icon to Project Detail View
|
## Stage 1: Auto-append .md Extension
|
||||||
**Goal**: Provide a clear way for users to return to the project list or previous page from the Project Browse Mode (DocumentPage).
|
**Goal**: Ensure new files created in the editor default to .md if no extension is provided.
|
||||||
**Success Criteria**: A Close icon is visible in the Sider Header and navigates the user back using history or fallback.
|
**Files**: `frontend/src/pages/Document/DocumentEditor.jsx`
|
||||||
**Status**: Complete
|
**Status**: In Progress
|
||||||
|
|
||||||
## Stage 2: Refine Navigation Logic
|
## Stage 2: Project Ownership Transfer
|
||||||
**Goal**: Ensure the back button handles entries from notifications or direct links correctly.
|
**Goal**: Allow project owners to transfer ownership to another user.
|
||||||
**Success Criteria**: Uses browser history if available, otherwise defaults to project list.
|
**Files**:
|
||||||
**Status**: Complete
|
- `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,
|
ProjectMemberResponse,
|
||||||
ProjectShareSettings,
|
ProjectShareSettings,
|
||||||
ProjectShareInfo,
|
ProjectShareInfo,
|
||||||
|
ProjectTransfer,
|
||||||
)
|
)
|
||||||
from app.schemas.response import success_response
|
from app.schemas.response import success_response
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
|
@ -263,6 +264,93 @@ async def update_project(
|
||||||
return success_response(data=project_data.dict(), message="项目更新成功")
|
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)
|
@router.delete("/{project_id}", response_model=dict)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
|
||||||
|
|
@ -90,3 +90,8 @@ class ProjectShareInfo(BaseModel):
|
||||||
share_url: str = Field(..., description="分享链接")
|
share_url: str = Field(..., description="分享链接")
|
||||||
has_password: bool = Field(..., description="是否设置了访问密码")
|
has_password: bool = Field(..., description="是否设置了访问密码")
|
||||||
access_pass: Optional[str] = Field(None, 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,
|
new_path: newPath,
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
const fileExists = checkFileExists(path)
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '文件已存在',
|
title: '文件已存在',
|
||||||
content: `文件 "${newName}" 已存在,是否覆盖?`,
|
content: `文件 "${finalName}" 已存在,是否覆盖?`,
|
||||||
okText: '覆盖',
|
okText: '覆盖',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
|
|
||||||
|
|
@ -612,7 +612,10 @@ function DocumentPage() {
|
||||||
// 复制分享链接
|
// 复制分享链接
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
if (!shareInfo) return
|
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 {
|
try {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
|
@ -931,7 +934,7 @@ function DocumentPage() {
|
||||||
{/* 分享模态框 */}
|
{/* 分享模态框 */}
|
||||||
{/* ... keeping the modal ... */}
|
{/* ... keeping the modal ... */}
|
||||||
<Modal
|
<Modal
|
||||||
title="分享项目"
|
title="分享"
|
||||||
open={shareModalVisible}
|
open={shareModalVisible}
|
||||||
onCancel={() => setShareModalVisible(false)}
|
onCancel={() => setShareModalVisible(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
|
|
@ -940,9 +943,14 @@ function DocumentPage() {
|
||||||
{shareInfo && (
|
{shareInfo && (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
<div>
|
<div>
|
||||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>分享链接</label>
|
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
|
||||||
|
{selectedFile ? '当前文件分享链接' : '项目分享链接'}
|
||||||
|
</label>
|
||||||
<Input
|
<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
|
readOnly
|
||||||
addonAfter={
|
addonAfter={
|
||||||
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
|
<CopyOutlined onClick={handleCopyLink} style={{ cursor: 'pointer' }} />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag } from 'antd'
|
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 { 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 } from '@/api/project'
|
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 { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||||
import { getUserList } from '@/api/users'
|
import { getUserList } from '@/api/users'
|
||||||
import { searchDocuments } from '@/api/search'
|
import { searchDocuments } from '@/api/search'
|
||||||
|
|
@ -33,12 +33,54 @@ function ProjectList({ type = 'my' }) {
|
||||||
const [editForm] = Form.useForm()
|
const [editForm] = Form.useForm()
|
||||||
const [gitForm] = Form.useForm()
|
const [gitForm] = Form.useForm()
|
||||||
const [memberForm] = Form.useForm()
|
const [memberForm] = Form.useForm()
|
||||||
|
const [transferModalVisible, setTransferModalVisible] = useState(false)
|
||||||
|
const [transferForm] = Form.useForm()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
}, [type])
|
}, [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 () => {
|
const fetchProjects = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -651,6 +693,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
|
|
@ -662,6 +705,14 @@ function ProjectList({ type = 'my' }) {
|
||||||
>
|
>
|
||||||
删除项目
|
删除项目
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<SwapOutlined />}
|
||||||
|
onClick={handleOpenTransfer}
|
||||||
|
>
|
||||||
|
转移所有权
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
更新
|
更新
|
||||||
|
|
@ -678,6 +729,45 @@ function ProjectList({ type = 'my' }) {
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
title="分享设置"
|
title="分享设置"
|
||||||
open={shareModalVisible}
|
open={shareModalVisible}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue