diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py
index 8eb8086e..6cbdb00a 100644
--- a/backend/app/api/v1/attachments.py
+++ b/backend/app/api/v1/attachments.py
@@ -3,17 +3,21 @@ import uuid
from pathlib import Path
import aiofiles
-from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.deps import get_current_user, get_db_session, require_study_member
+from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member
from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import study as study_crud
+from app.crud import user as user_crud
+from app.crud import member as member_crud
+from app.core.security import decode_token
from app.schemas.attachment import AttachmentRead
router = APIRouter()
+global_router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads"
@@ -111,3 +115,130 @@ async def download_attachment(
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
)
+
+
+async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
+ token = None
+ auth_header = request.headers.get("Authorization")
+ if auth_header and auth_header.lower().startswith("bearer "):
+ token = auth_header.split(" ", 1)[1]
+ if not token:
+ token = request.query_params.get("token")
+ if not token:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
+ payload = decode_token(token)
+ user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
+ if not user or not user.is_active:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user")
+ if user.role == "ADMIN":
+ return user, None
+ membership = await member_crud.get_member(db, study_id, user.id)
+ if not membership or not membership.is_active:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not project member")
+ return user, membership
+
+
+@global_router.get(
+ "/{attachment_id}/download",
+ response_class=FileResponse,
+)
+async def global_download_attachment(
+ attachment_id: uuid.UUID,
+ request: Request,
+ db: AsyncSession = Depends(get_db_session),
+):
+ attachment = await attachment_crud.get_attachment(db, attachment_id)
+ if not attachment:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found")
+ await _ensure_study_exists(db, attachment.study_id)
+ user, _ = await _authorize_global(request, db, attachment.study_id)
+ if not os.path.exists(attachment.file_path):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File missing on server")
+ return FileResponse(
+ path=attachment.file_path,
+ filename=attachment.filename,
+ media_type=attachment.content_type or "application/octet-stream",
+ )
+
+
+@global_router.delete(
+ "/{attachment_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def global_delete_attachment(
+ attachment_id: uuid.UUID,
+ request: Request,
+ db: AsyncSession = Depends(get_db_session),
+):
+ attachment = await attachment_crud.get_attachment(db, attachment_id)
+ if not attachment:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found")
+ await _ensure_study_exists(db, attachment.study_id)
+ user, membership = await _authorize_global(request, db, attachment.study_id)
+ can_delete = (
+ user.role == "ADMIN"
+ or attachment.uploaded_by == user.id
+ or (membership and getattr(membership, "role_in_study", None) == "PM")
+ )
+ if not can_delete:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to delete attachment")
+ await attachment_crud.soft_delete_attachment(db, attachment)
+ await audit_crud.log_action(
+ db,
+ study_id=attachment.study_id,
+ entity_type=attachment.entity_type,
+ entity_id=attachment.entity_id,
+ action="DELETE_ATTACHMENT",
+ detail=f"File deleted: {attachment.filename}",
+ operator_id=user.id,
+ operator_role=user.role,
+ )
+
+
+@router.delete(
+ "/{attachment_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ dependencies=[Depends(require_study_member())],
+)
+async def delete_attachment(
+ study_id: uuid.UUID,
+ entity_type: str,
+ entity_id: uuid.UUID,
+ attachment_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db_session),
+ current_user=Depends(get_current_user),
+):
+ await _ensure_study_exists(db, study_id)
+ attachment = await attachment_crud.get_attachment(db, attachment_id)
+ if (
+ not attachment
+ or attachment.study_id != study_id
+ or attachment.entity_id != entity_id
+ or attachment.entity_type != entity_type
+ or attachment.is_deleted
+ ):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found")
+
+ membership = None
+ if current_user.role != "ADMIN":
+ membership = await get_study_member(study_id, current_user=current_user, db=db)
+
+ can_delete = (
+ current_user.role == "ADMIN"
+ or attachment.uploaded_by == current_user.id
+ or (membership and getattr(membership, "role_in_study", None) == "PM")
+ )
+ if not can_delete:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to delete attachment")
+
+ await attachment_crud.soft_delete_attachment(db, attachment)
+ await audit_crud.log_action(
+ db,
+ study_id=study_id,
+ entity_type=entity_type,
+ entity_id=entity_id,
+ action="DELETE_ATTACHMENT",
+ detail=f"File deleted: {attachment.filename}",
+ operator_id=current_user.id,
+ operator_role=current_user.role,
+ )
diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py
index 96aca791..b574c1b0 100644
--- a/backend/app/api/v1/router.py
+++ b/backend/app/api/v1/router.py
@@ -10,6 +10,7 @@ api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
api_router.include_router(comments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/comments", tags=["comments"])
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
+api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"])
api_router.include_router(tasks.router, prefix="/studies/{study_id}/tasks", tags=["tasks"])
diff --git a/backend/app/crud/attachment.py b/backend/app/crud/attachment.py
index 72cf739a..4f90e75a 100644
--- a/backend/app/crud/attachment.py
+++ b/backend/app/crud/attachment.py
@@ -57,3 +57,11 @@ async def list_attachments(
async def get_attachment(db: AsyncSession, attachment_id: uuid.UUID) -> Attachment | None:
result = await db.execute(select(Attachment).where(Attachment.id == attachment_id, Attachment.is_deleted.is_(False)))
return result.scalar_one_or_none()
+
+
+async def soft_delete_attachment(db: AsyncSession, attachment: Attachment) -> Attachment:
+ attachment.is_deleted = True
+ db.add(attachment)
+ await db.commit()
+ await db.refresh(attachment)
+ return attachment
diff --git a/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/009bba02-c940-464c-b08d-921397add75a.xlsx b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/009bba02-c940-464c-b08d-921397add75a.xlsx
new file mode 100644
index 00000000..7669366c
Binary files /dev/null and b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/009bba02-c940-464c-b08d-921397add75a.xlsx differ
diff --git a/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/67d8d756-d9d1-4bef-9a9b-03bfa4745759.html b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/67d8d756-d9d1-4bef-9a9b-03bfa4745759.html
new file mode 100644
index 00000000..ef26d201
--- /dev/null
+++ b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/67d8d756-d9d1-4bef-9a9b-03bfa4745759.html
@@ -0,0 +1,144 @@
+
+
+
+
+
+ 华邦制药 - 临床试验 AI 知识库
+
+
+
+
+
+
+
+
+
+
+
+
+ 欢迎使用 临床试验 AI 知识库
+
+
+
+
+
+
+
Enter 发送
+
+
+
+
+
+
+
正在检索华邦制药知识库...
+
+
+
+
+
+ 🤖
+
+
+
AI 回答:
+
+ 根据临床试验数据库检索,该药物常见的不良反应主要包括:
+ 1. 胃肠道反应:约 15% 的患者报告有轻度恶心、呕吐或腹泻症状,通常在服药后一周内自行缓解。
+ 2. 神经系统:偶见头晕(3%)和嗜睡(2%)。
+ 3. 实验室检查异常:极少数病例出现转氨酶轻度升高,建议用药期间定期监测肝功能。
+ 数据来源:Ⅲ期临床试验报告文档 (Doc-2023-08)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/e40467fb-089d-403d-ba3d-d6a62531d36a.pdf b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/e40467fb-089d-403d-ba3d-d6a62531d36a.pdf
new file mode 100644
index 00000000..2ce0eea1
Binary files /dev/null and b/backend/app/uploads/study_6b39ddef-9f77-4175-9d42-ab53d1f12fb0/aes_eca50e66-5f85-43db-b01c-b6044b02b5b5/e40467fb-089d-403d-ba3d-d6a62531d36a.pdf differ
diff --git a/frontend/src/api/attachments.ts b/frontend/src/api/attachments.ts
index 8a87eb88..876d8b21 100644
--- a/frontend/src/api/attachments.ts
+++ b/frontend/src/api/attachments.ts
@@ -1,12 +1,21 @@
-import { apiGet, apiPost } from "./axios";
+import { apiGet, apiPost, apiDelete } from "./axios";
export const fetchAttachments = (studyId: string, entityType: string, entityId: string) =>
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`);
-export const uploadAttachment = (studyId: string, entityType: string, entityId: string, file: File) => {
+export const uploadAttachment = (
+ studyId: string,
+ entityType: string,
+ entityId: string,
+ file: File,
+ options?: Record
+) => {
const formData = new FormData();
formData.append("file", file);
return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`, formData, {
headers: { "Content-Type": "multipart/form-data" },
+ ...options,
});
};
+
+export const deleteAttachment = (attachmentId: string) => apiDelete(`/api/v1/attachments/${attachmentId}`);
diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts
index 5cf7fe7a..a3d95c25 100644
--- a/frontend/src/api/axios.ts
+++ b/frontend/src/api/axios.ts
@@ -52,5 +52,7 @@ export const apiPost = (url: string, data?: unknown, config?: Axios
instance.post(url, data, config);
export const apiPatch = (url: string, data?: unknown, config?: AxiosRequestConfig) =>
instance.patch(url, data, config);
+export const apiDelete = (url: string, config?: AxiosRequestConfig) =>
+ instance.delete(url, config);
export default instance;
diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue
new file mode 100644
index 00000000..0c52a4a9
--- /dev/null
+++ b/frontend/src/components/attachments/AttachmentList.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+ {{ formatFileSize(scope.row.file_size) }}
+
+
+
+
+
+ 下载
+
+ 删除
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/attachments/AttachmentUploader.vue b/frontend/src/components/attachments/AttachmentUploader.vue
new file mode 100644
index 00000000..41b58d73
--- /dev/null
+++ b/frontend/src/components/attachments/AttachmentUploader.vue
@@ -0,0 +1,67 @@
+
+
+
+ 上传附件
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/attachments/attachmentUtils.ts b/frontend/src/components/attachments/attachmentUtils.ts
new file mode 100644
index 00000000..db1f68c6
--- /dev/null
+++ b/frontend/src/components/attachments/attachmentUtils.ts
@@ -0,0 +1,6 @@
+export const formatFileSize = (bytes: number): string => {
+ if (!bytes && bytes !== 0) return "-";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+};
diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts
index de86c45e..e4c69481 100644
--- a/frontend/src/utils/permission.ts
+++ b/frontend/src/utils/permission.ts
@@ -19,6 +19,7 @@ const PERMISSIONS: Record = {
"finance.pay": ["ADMIN", "PM"],
"imp.transaction": ["ADMIN", "PM", "IMP"],
"faq.edit": ["ADMIN", "PM"],
+ "dataquery.edit": ["ADMIN", "PM", "CRA"],
};
const REASONS: Record = {
@@ -33,6 +34,7 @@ const REASONS: Record = {
"finance.pay": "支付确认需要 PM/ADMIN 权限",
"imp.transaction": "仅 IMP/PM/ADMIN 可操作台账",
"faq.edit": "仅 PM/ADMIN 可维护 FAQ",
+ "dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题",
};
export const usePermission = () => {
diff --git a/frontend/src/views/AeDetail.vue b/frontend/src/views/AeDetail.vue
index 25848eb8..93c7466b 100644
--- a/frontend/src/views/AeDetail.vue
+++ b/frontend/src/views/AeDetail.vue
@@ -27,7 +27,12 @@
-
+
@@ -44,7 +49,7 @@ import { fetchAes, updateAe } from "../api/aes";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
-import AttachmentList from "../components/AttachmentList.vue";
+import AttachmentList from "../components/attachments/AttachmentList.vue";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
diff --git a/frontend/src/views/DataQueryDetail.vue b/frontend/src/views/DataQueryDetail.vue
index 1ae256e5..1e116300 100644
--- a/frontend/src/views/DataQueryDetail.vue
+++ b/frontend/src/views/DataQueryDetail.vue
@@ -27,7 +27,12 @@
-
+
@@ -45,7 +50,8 @@ import { fetchSubjects } from "../api/subjects";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
-import AttachmentList from "../components/AttachmentList.vue";
+import AttachmentList from "../components/attachments/AttachmentList.vue";
+import { usePermission } from "../utils/permission";
const route = useRoute();
const study = useStudyStore();
@@ -61,10 +67,8 @@ const subjectMap = computed(() =>
}, {})
);
-const canEdit = computed(() => {
- const role = auth.user?.role;
- return role === "ADMIN" || role === "PM" || role === "CRA";
-});
+const { can } = usePermission();
+const canEdit = computed(() => can("dataquery.edit"));
const enrichOverdue = (item: any) => {
if (!item) return item;
diff --git a/frontend/src/views/FinanceDetail.vue b/frontend/src/views/FinanceDetail.vue
index 1f0313d0..c9a5f1d0 100644
--- a/frontend/src/views/FinanceDetail.vue
+++ b/frontend/src/views/FinanceDetail.vue
@@ -28,6 +28,15 @@
+
+
+
+
@@ -39,6 +48,7 @@ import { ElMessage } from "element-plus";
import FinanceStatusActions from "../components/FinanceStatusActions.vue";
import FinanceForm from "../components/FinanceForm.vue";
import PermissionAction from "../components/PermissionAction.vue";
+import AttachmentList from "../components/attachments/AttachmentList.vue";
import { fetchFinanceItem } from "../api/finance";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
diff --git a/frontend/src/views/IssueDetail.vue b/frontend/src/views/IssueDetail.vue
index dd68df83..945b17e1 100644
--- a/frontend/src/views/IssueDetail.vue
+++ b/frontend/src/views/IssueDetail.vue
@@ -24,7 +24,12 @@
-
+
@@ -43,7 +48,7 @@ import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import CommentList from "../components/CommentList.vue";
-import AttachmentList from "../components/AttachmentList.vue";
+import AttachmentList from "../components/attachments/AttachmentList.vue";
const route = useRoute();
const study = useStudyStore();
diff --git a/pg_data/base/16384/24576 b/pg_data/base/16384/24576
index 06789f86..b7500176 100644
Binary files a/pg_data/base/16384/24576 and b/pg_data/base/16384/24576 differ
diff --git a/pg_data/base/16384/24584 b/pg_data/base/16384/24584
index 40bb823a..f490b984 100644
Binary files a/pg_data/base/16384/24584 and b/pg_data/base/16384/24584 differ
diff --git a/pg_data/base/16384/24653 b/pg_data/base/16384/24653
index ddfe83c0..aab22617 100644
Binary files a/pg_data/base/16384/24653 and b/pg_data/base/16384/24653 differ
diff --git a/pg_data/base/16384/24653_fsm b/pg_data/base/16384/24653_fsm
new file mode 100644
index 00000000..39488e54
Binary files /dev/null and b/pg_data/base/16384/24653_fsm differ
diff --git a/pg_data/base/16384/24659 b/pg_data/base/16384/24659
index 2412d970..cecab445 100644
Binary files a/pg_data/base/16384/24659 and b/pg_data/base/16384/24659 differ
diff --git a/pg_data/base/16384/24671 b/pg_data/base/16384/24671
index e69de29b..fafa858c 100644
Binary files a/pg_data/base/16384/24671 and b/pg_data/base/16384/24671 differ
diff --git a/pg_data/base/16384/24678 b/pg_data/base/16384/24678
index b2e3806c..9c876789 100644
Binary files a/pg_data/base/16384/24678 and b/pg_data/base/16384/24678 differ
diff --git a/pg_data/base/16384/24727 b/pg_data/base/16384/24727
index 186d74ba..dba6e1d5 100644
Binary files a/pg_data/base/16384/24727 and b/pg_data/base/16384/24727 differ
diff --git a/pg_data/base/16384/24734 b/pg_data/base/16384/24734
index 87857928..e2261d50 100644
Binary files a/pg_data/base/16384/24734 and b/pg_data/base/16384/24734 differ
diff --git a/pg_data/base/16384/24736 b/pg_data/base/16384/24736
index adb46673..7f5d5c2f 100644
Binary files a/pg_data/base/16384/24736 and b/pg_data/base/16384/24736 differ
diff --git a/pg_data/base/16384/24743 b/pg_data/base/16384/24743
index d399f8be..b312aa71 100644
Binary files a/pg_data/base/16384/24743 and b/pg_data/base/16384/24743 differ
diff --git a/pg_data/base/16384/24774 b/pg_data/base/16384/24774
index 26b1bb49..b567d4a7 100644
Binary files a/pg_data/base/16384/24774 and b/pg_data/base/16384/24774 differ
diff --git a/pg_data/base/16384/24844 b/pg_data/base/16384/24844
index 16cd3703..d74a3a32 100644
Binary files a/pg_data/base/16384/24844 and b/pg_data/base/16384/24844 differ
diff --git a/pg_data/base/16384/24851 b/pg_data/base/16384/24851
index d60f87c4..9ba51b90 100644
Binary files a/pg_data/base/16384/24851 and b/pg_data/base/16384/24851 differ
diff --git a/pg_data/base/16384/24863 b/pg_data/base/16384/24863
index 3602f6fe..9bb0d290 100644
Binary files a/pg_data/base/16384/24863 and b/pg_data/base/16384/24863 differ
diff --git a/pg_data/base/16384/2604 b/pg_data/base/16384/2604
index aade99f3..d25b31b1 100644
Binary files a/pg_data/base/16384/2604 and b/pg_data/base/16384/2604 differ
diff --git a/pg_data/base/16384/2619 b/pg_data/base/16384/2619
index b6961db9..5ebdd5c9 100644
Binary files a/pg_data/base/16384/2619 and b/pg_data/base/16384/2619 differ
diff --git a/pg_data/base/16384/pg_internal.init b/pg_data/base/16384/pg_internal.init
index 5b9da4ae..7704cf4b 100644
Binary files a/pg_data/base/16384/pg_internal.init and b/pg_data/base/16384/pg_internal.init differ
diff --git a/pg_data/global/pg_control b/pg_data/global/pg_control
index f24d8e62..678f7e3b 100644
Binary files a/pg_data/global/pg_control and b/pg_data/global/pg_control differ
diff --git a/pg_data/global/pg_internal.init b/pg_data/global/pg_internal.init
index a358cc6a..6eb43a48 100644
Binary files a/pg_data/global/pg_internal.init and b/pg_data/global/pg_internal.init differ
diff --git a/pg_data/pg_wal/000000010000000000000001 b/pg_data/pg_wal/000000010000000000000001
index 40f86eb4..7ae3ff9e 100644
Binary files a/pg_data/pg_wal/000000010000000000000001 and b/pg_data/pg_wal/000000010000000000000001 differ
diff --git a/pg_data/pg_xact/0000 b/pg_data/pg_xact/0000
index ebdf2ee7..2ddd1880 100644
Binary files a/pg_data/pg_xact/0000 and b/pg_data/pg_xact/0000 differ
diff --git a/pg_data/postmaster.pid b/pg_data/postmaster.pid
index 85358356..9b4aed74 100644
--- a/pg_data/postmaster.pid
+++ b/pg_data/postmaster.pid
@@ -1,6 +1,6 @@
1
/var/lib/postgresql/data
-1765940756
+1765957947
5432
/var/run/postgresql
*