diff --git a/backend/app/schemas/document_version.py b/backend/app/schemas/document_version.py
index 6e54c528..7528e996 100644
--- a/backend/app/schemas/document_version.py
+++ b/backend/app/schemas/document_version.py
@@ -5,6 +5,8 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict
+from app.schemas.user import UserDisplay
+
class DocumentVersionStatus(str, enum.Enum):
DRAFT = "DRAFT"
@@ -39,6 +41,7 @@ class DocumentVersionRead(BaseModel):
mime_type: Optional[str] = None
change_summary: Optional[str] = None
created_by: Optional[uuid.UUID] = None
+ created_by_user: Optional[UserDisplay] = None
submitted_at: Optional[datetime] = None
approved_at: Optional[datetime] = None
withdrawn_at: Optional[datetime] = None
diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py
index b6cdf5e1..4d8c71ca 100644
--- a/backend/app/services/document_service.py
+++ b/backend/app/services/document_service.py
@@ -77,6 +77,13 @@ def _version_snapshot(version: DocumentVersion) -> dict:
}
+def _version_read(version: DocumentVersion, users_by_id: dict[uuid.UUID, object] | None = None) -> DocumentVersionRead:
+ creator = users_by_id.get(version.created_by) if users_by_id and version.created_by else None
+ return DocumentVersionRead.model_validate(version).model_copy(
+ update={"created_by_user": UserDisplay.model_validate(creator) if creator else None}
+ )
+
+
async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_user, action: str):
study = await study_crud.get(db, trial_id)
if not study:
@@ -242,6 +249,8 @@ async def get_document_detail(
owner_obj = await user_crud.get_by_id(db, doc.owner_id)
if owner_obj:
owner = UserDisplay.model_validate(owner_obj)
+ creator_ids = {v.created_by for v in versions if v.created_by}
+ version_creators = await user_crud.get_users_by_ids(db, creator_ids)
return DocumentDetail(
id=doc.id,
trial_id=doc.trial_id,
@@ -257,7 +266,7 @@ async def get_document_detail(
created_at=doc.created_at,
updated_at=doc.updated_at,
owner=owner,
- version_timeline=[DocumentVersionRead.model_validate(v) for v in versions],
+ version_timeline=[_version_read(v, version_creators) for v in versions],
distribution_stats=distribution_stats,
)
diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue
index aeeedacf..46f6ca42 100644
--- a/frontend/src/components/attachments/AttachmentList.vue
+++ b/frontend/src/components/attachments/AttachmentList.vue
@@ -55,19 +55,28 @@
-
-
-
{{ group.label }}
+
+
-
-
-
点击上传文件
+
+
{{ group.label }}
+
+
+ {{ group.uploadText || "点击上传文件" }}
+
@@ -112,7 +121,7 @@
diff --git a/frontend/src/views/documents/DocumentList.test.ts b/frontend/src/views/documents/DocumentList.test.ts
index a78fc646..333221c5 100644
--- a/frontend/src/views/documents/DocumentList.test.ts
+++ b/frontend/src/views/documents/DocumentList.test.ts
@@ -28,4 +28,24 @@ describe("DocumentList permissions", () => {
expect(source).not.toContain("
{
+ const source = readSource();
+
+ expect(source).toContain("useDrawerDirtyGuard");
+ expect(source).toContain('const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);');
+ expect(source).toContain(':close-on-click-modal="true"');
+ expect(source).toContain(':before-close="editorDirtyGuard.beforeClose"');
+ expect(source).toContain("editorDirtyGuard.syncBaseline()");
+ });
+
+ it("uses the shared datetime formatter and splits updated-at into two lines", () => {
+ const source = readSource();
+
+ expect(source).toContain("displayDateTime");
+ expect(source).toContain("splitDateTime(row.updated_at).date");
+ expect(source).toContain("splitDateTime(row.updated_at).time");
+ expect(source).toContain("const displayValue = displayDateTime(value)");
+ expect(source).not.toContain('replace("T", " ").replace("Z", "")');
+ });
});
diff --git a/frontend/src/views/documents/DocumentList.vue b/frontend/src/views/documents/DocumentList.vue
index 063851a2..abe5b69b 100644
--- a/frontend/src/views/documents/DocumentList.vue
+++ b/frontend/src/views/documents/DocumentList.vue
@@ -70,17 +70,22 @@
- {{ formatDate(row.updated_at) }}
+
+ {{ splitDateTime(row.updated_at).date }}
+ {{ splitDateTime(row.updated_at).time }}
+
-
+
-
- {{ TEXT.common.actions.edit }}
-
-
- {{ TEXT.common.actions.delete }}
-
+
+
+ {{ TEXT.common.actions.edit }}
+
+
+ {{ TEXT.common.actions.delete }}
+
+
@@ -96,6 +101,7 @@
direction="rtl"
size="620px"
:close-on-click-modal="true"
+ :before-close="editorDirtyGuard.beforeClose"
:show-close="false"
destroy-on-close
class="document-editor-drawer"
@@ -167,9 +173,10 @@ import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { usePermission } from "../../utils/permission";
+import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import type { DocumentSummary } from "../../types/documents";
import type { Site } from "../../types/api";
-import { displayEnum, displayText } from "../../utils/display";
+import { displayDateTime, displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales";
const route = useRoute();
@@ -239,6 +246,7 @@ const editorForm = reactive({
site_id: "",
doc_type: "",
});
+const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const editorRules: FormRules = {
doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
@@ -318,6 +326,7 @@ const openCreate = () => {
}
editingDocumentId.value = "";
resetEditorForm();
+ editorDirtyGuard.syncBaseline();
editorVisible.value = true;
};
@@ -339,6 +348,7 @@ const openEdit = (row: DocumentSummary) => {
doc_type: row.doc_type || "",
});
editorFormRef.value?.clearValidate();
+ editorDirtyGuard.syncBaseline();
editorVisible.value = true;
};
@@ -416,9 +426,10 @@ const confirmDelete = async (row: DocumentSummary) => {
}
};
-const formatDate = (value?: string | null) => {
- if (!value) return TEXT.common.fallback;
- return value.replace("T", " ").replace("Z", "").split(".")[0];
+const splitDateTime = (value?: string | null) => {
+ const displayValue = displayDateTime(value);
+ const [date, time] = displayValue.split(" ");
+ return { date, time: time || "" };
};
onMounted(() => {
@@ -461,10 +472,6 @@ watch(
width: 132px;
}
-.ctms-table :deep(.el-table__inner-wrapper::before) {
- display: none;
-}
-
.filter-spacer {
flex: 1;
}
@@ -524,6 +531,93 @@ watch(
gap: 10px;
}
+/* ==================== Table Styling (DrugShipments style) ==================== */
+.document-table-section {
+ background: #ffffff;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.04),
+ 0 2px 8px rgba(0, 0, 0, 0.02);
+}
+
+.ctms-table {
+ --el-table-border-color: transparent;
+ --el-table-row-hover-bg-color: #f8f9fb;
+}
+
+.ctms-table :deep(.el-table__inner-wrapper::before) {
+ display: none;
+}
+
+.ctms-table :deep(th.el-table__cell) {
+ background: #f4f5f7;
+ color: #2a2a2a;
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 14px 14px;
+ border-bottom: 2px solid #e0e0e0 !important;
+}
+
+.ctms-table :deep(td.el-table__cell) {
+ padding: 12px 14px;
+ color: #0a0a0a;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.ctms-table :deep(.el-table__body tr) {
+ cursor: pointer;
+ transition: background 0.1s ease;
+}
+
+.ctms-table :deep(.font-semibold) {
+ font-weight: 600;
+ color: #0a0a0a;
+}
+
+.ctms-table :deep(.text-secondary) {
+ color: #8a8a8a;
+}
+
+.ctms-table :deep(.datetime-stack) {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+ line-height: 1.25;
+ white-space: nowrap;
+}
+
+.ctms-table :deep(.datetime-stack__time) {
+ color: #9a9a9a;
+ font-size: 11px;
+}
+
+.ctms-table :deep(.el-tag) {
+ font-weight: 600;
+ border: none;
+}
+
+.ctms-table :deep(.cell-actions) {
+ display: flex;
+ gap: 8px;
+ white-space: nowrap;
+}
+
+.ctms-table :deep(.cell-actions .el-button) {
+ font-size: 13px;
+ padding: 0;
+ height: auto;
+}
+
+.ctms-table :deep(.cell-actions .el-button + .el-button) {
+ margin-left: 0;
+}
diff --git a/frontend/src/views/fees/ContractFeeDetail.vue b/frontend/src/views/fees/ContractFeeDetail.vue
index be95fa0a..70cb5be6 100644
--- a/frontend/src/views/fees/ContractFeeDetail.vue
+++ b/frontend/src/views/fees/ContractFeeDetail.vue
@@ -9,111 +9,137 @@
-
-
-