UX-3 附件上传删除功能

This commit is contained in:
Cheng Zhou
2025-12-17 16:16:47 +08:00
parent 9ca30d12f8
commit 1e6cae6b6e
39 changed files with 514 additions and 15 deletions
+133 -2
View File
@@ -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,
)
+1
View File
@@ -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"])
+8
View File
@@ -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
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>华邦制药 - 临床试验 AI 知识库</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* 简单的淡入动画 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
</head>
<body class="min-h-screen bg-slate-50 relative overflow-hidden font-sans text-slate-700">
<div class="absolute inset-0" style="
background-image: radial-gradient(#cbd5e1 1px, transparent 1px);
background-size: 30px 30px;
opacity: 0.5;
z-index: 0;
"></div>
<div class="relative z-10 flex justify-between items-center p-6 max-w-7xl mx-auto">
<div class="flex items-center gap-3">
<div class="w-8 h-8 flex items-center justify-center bg-teal-600 rounded-lg shadow-sm text-white">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<span class="font-bold text-xl text-slate-800 tracking-tight">华邦制药</span>
</div>
<div class="text-xs font-medium text-slate-500 bg-white/80 backdrop-blur px-3 py-1.5 rounded-md shadow-sm border border-slate-200 cursor-default">
⌘ K 智能问答
</div>
</div>
<main class="relative z-10 flex flex-col items-center mt-24 md:mt-32 px-4 w-full">
<h1 class="text-3xl md:text-5xl font-bold text-teal-700 mb-12 tracking-wide text-center">
欢迎使用 临床试验 AI 知识库
</h1>
<div class="w-full max-w-3xl bg-white rounded-2xl shadow-xl border border-slate-100 p-2 transition-shadow hover:shadow-2xl">
<textarea
id="questionInput"
placeholder="请输入您的问题,例如:该药物的临床不良反应有哪些?"
class="w-full h-32 p-5 text-lg text-slate-700 placeholder:text-slate-300 outline-none resize-none rounded-xl bg-transparent"
></textarea>
<div class="flex justify-between items-center px-2 pb-2">
<div class="text-slate-300 text-sm px-3">Enter 发送</div>
<button
id="searchBtn"
onclick="simulateSearch()"
class="flex items-center gap-2 px-6 py-2 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-full shadow-md hover:shadow-lg transition-all active:scale-95"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
AI 智能问答
</button>
</div>
</div>
<div id="loadingState" class="hidden mt-8 flex items-center gap-3 text-teal-600 bg-white/50 px-4 py-2 rounded-full">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm font-medium">正在检索华邦制药知识库...</span>
</div>
<div id="resultCard" class="hidden w-full max-w-3xl mt-8 bg-white/90 backdrop-blur rounded-xl p-8 border border-teal-100 shadow-lg animate-fade-in">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-teal-100 flex items-center justify-center shrink-0">
<span class="text-xl">🤖</span>
</div>
<div class="space-y-4">
<h3 class="text-lg font-bold text-teal-800">AI 回答:</h3>
<p class="leading-relaxed text-slate-700">
根据临床试验数据库检索,该药物常见的不良反应主要包括:<br><br>
1. <strong>胃肠道反应</strong>:约 15% 的患者报告有轻度恶心、呕吐或腹泻症状,通常在服药后一周内自行缓解。<br>
2. <strong>神经系统</strong>:偶见头晕(3%)和嗜睡(2%)。<br>
3. <strong>实验室检查异常</strong>:极少数病例出现转氨酶轻度升高,建议用药期间定期监测肝功能。<br><br>
<span class="text-sm text-slate-400">数据来源:Ⅲ期临床试验报告文档 (Doc-2023-08)</span>
</p>
<div class="flex gap-2 mt-4">
<button class="text-xs bg-slate-100 hover:bg-slate-200 text-slate-600 px-3 py-1 rounded">复制答案</button>
<button class="text-xs bg-slate-100 hover:bg-slate-200 text-slate-600 px-3 py-1 rounded">不满意</button>
</div>
</div>
</div>
</div>
<div class="mt-12">
<button class="px-8 py-3 bg-teal-600/5 text-teal-700 rounded-lg hover:bg-teal-600 hover:text-white transition-colors font-medium border border-teal-600/20">
查看原始文档库
</button>
</div>
</main>
<script>
function simulateSearch() {
const input = document.getElementById('questionInput');
const btn = document.getElementById('searchBtn');
const loading = document.getElementById('loadingState');
const result = document.getElementById('resultCard');
if (!input.value.trim()) {
input.focus();
return;
}
// 1. 进入加载状态
btn.innerHTML = '思考中...';
btn.classList.add('opacity-75', 'cursor-not-allowed');
loading.classList.remove('hidden');
result.classList.add('hidden'); // 隐藏旧结果
// 2. 模拟网络延迟 (1.5秒)
setTimeout(() => {
// 3. 显示结果
loading.classList.add('hidden');
result.classList.remove('hidden');
// 恢复按钮
btn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
AI 智能问答
`;
btn.classList.remove('opacity-75', 'cursor-not-allowed');
}, 1500);
}
</script>
</body>
</html>
+11 -2
View File
@@ -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<string, any>
) => {
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}`);
+2
View File
@@ -52,5 +52,7 @@ export const apiPost = <T = unknown>(url: string, data?: unknown, config?: Axios
instance.post<T>(url, data, config);
export const apiPatch = <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
instance.patch<T>(url, data, config);
export const apiDelete = <T = unknown>(url: string, config?: AxiosRequestConfig) =>
instance.delete<T>(url, config);
export default instance;
@@ -0,0 +1,105 @@
<template>
<el-card>
<div class="header">
<span>附件</span>
<AttachmentUploader
:study-id="studyId"
:entity-type="entityType"
:entity-id="entityId"
@uploaded="load"
/>
</div>
<el-table :data="attachments" v-loading="loading" style="width: 100%">
<el-table-column prop="filename" label="文件名" min-width="200" />
<el-table-column label="大小" width="120">
<template #default="scope">{{ formatFileSize(scope.row.file_size) }}</template>
</el-table-column>
<el-table-column prop="uploaded_by" label="上传人" width="160" />
<el-table-column prop="uploaded_at" label="上传时间" width="180" />
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button link type="primary" size="small" @click="download(scope.row)">下载</el-button>
<el-button
v-if="canDelete(scope.row)"
link
type="danger"
size="small"
@click="remove(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchAttachments, deleteAttachment } from "../../api/attachments";
import AttachmentUploader from "./AttachmentUploader.vue";
import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
const props = defineProps<{
studyId: string;
entityType: string;
entityId: string;
}>();
const attachments = ref<any[]>([]);
const loading = ref(false);
const auth = useAuthStore();
const study = useStudyStore();
const load = async () => {
if (!props.studyId || !props.entityId) return;
loading.value = true;
try {
const { data } = await fetchAttachments(props.studyId, props.entityType, props.entityId);
attachments.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "附件加载失败");
} finally {
loading.value = false;
}
};
const download = (row: any) => {
const token = localStorage.getItem("ctms_token");
const url = token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`;
window.open(url, "_blank");
};
const canDelete = (row: any) => {
const userId = auth.user?.id;
const role = auth.user?.role;
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
return userId === row.uploaded_by || role === "ADMIN" || projectRole === "PM";
};
const remove = async (row: any) => {
const ok = await ElMessageBox.confirm("确认删除该附件?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteAttachment(row.id);
ElMessage.success("已删除");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
onMounted(load);
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,67 @@
<template>
<div class="uploader">
<el-upload
:http-request="doUpload"
:show-file-list="false"
:limit="1"
:disabled="loading"
:auto-upload="true"
>
<el-button type="primary" :loading="loading">上传附件</el-button>
</el-upload>
<el-progress v-if="progress > 0 && progress < 100" :percentage="progress" :stroke-width="6" />
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { uploadAttachment } from "../../api/attachments";
const props = defineProps<{
studyId: string;
entityType: string;
entityId: string;
maxSizeMb?: number;
}>();
const emit = defineEmits(["uploaded"]);
const progress = ref(0);
const loading = ref(false);
const maxSize = props.maxSizeMb ?? 50;
const doUpload = async (options: any) => {
const file: File = options.file;
if (!file) return;
if (file.size > maxSize * 1024 * 1024) {
ElMessage.error(`文件大小不能超过 ${maxSize}MB`);
return;
}
progress.value = 0;
loading.value = true;
try {
await uploadAttachment(props.studyId, props.entityType, props.entityId, file, {
onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) {
progress.value = Math.round((evt.loaded / evt.total) * 100);
}
},
});
ElMessage.success("上传成功");
emit("uploaded");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "上传失败");
} finally {
progress.value = 0;
loading.value = false;
}
};
</script>
<style scoped>
.uploader {
display: flex;
gap: 8px;
align-items: center;
}
</style>
@@ -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`;
};
+2
View File
@@ -19,6 +19,7 @@ const PERMISSIONS: Record<string, string[]> = {
"finance.pay": ["ADMIN", "PM"],
"imp.transaction": ["ADMIN", "PM", "IMP"],
"faq.edit": ["ADMIN", "PM"],
"dataquery.edit": ["ADMIN", "PM", "CRA"],
};
const REASONS: Record<string, string> = {
@@ -33,6 +34,7 @@ const REASONS: Record<string, string> = {
"finance.pay": "支付确认需要 PM/ADMIN 权限",
"imp.transaction": "仅 IMP/PM/ADMIN 可操作台账",
"faq.edit": "仅 PM/ADMIN 可维护 FAQ",
"dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题",
};
export const usePermission = () => {
+7 -2
View File
@@ -27,7 +27,12 @@
<CommentList :study-id="studyId" entity-type="aes" :entity-id="ae.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList :study-id="studyId" entity-type="aes" :entity-id="ae.id" :can-upload="canClose" />
<AttachmentList
:study-id="studyId"
entity-type="aes"
:entity-id="ae.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
</div>
@@ -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";
+10 -6
View File
@@ -27,7 +27,12 @@
<CommentList :study-id="studyId" entity-type="data-queries" :entity-id="query.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList :study-id="studyId" entity-type="data-queries" :entity-id="query.id" :can-upload="canEdit" />
<AttachmentList
:study-id="studyId"
entity-type="data-queries"
:entity-id="query.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
</div>
@@ -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;
+10
View File
@@ -28,6 +28,15 @@
</el-descriptions>
</el-card>
<el-card class="mt-12" v-if="item">
<AttachmentList
:study-id="study.currentStudy?.id || ''"
entity-type="finance"
:entity-id="item.id"
:show-uploader="true"
/>
</el-card>
<FinanceForm v-model="showEdit" :item="item" @success="loadItem" />
</div>
</template>
@@ -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";
+7 -2
View File
@@ -24,7 +24,12 @@
<CommentList :study-id="studyId" entity-type="issues" :entity-id="issue.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList :study-id="studyId" entity-type="issues" :entity-id="issue.id" :can-upload="canEdit" />
<AttachmentList
:study-id="studyId"
entity-type="issues"
:entity-id="issue.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
</div>
@@ -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();
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
1
/var/lib/postgresql/data
1765940756
1765957947
5432
/var/run/postgresql
*