From 8cd8c3890050c5651c028a6be549e646adedf8d6 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 8 Jun 2026 11:06:07 +0800 Subject: [PATCH] feat(ia): polish project operation workspaces --- backend/app/crud/overview.py | 6 +- frontend/src/locales/zh-CN.ts | 5 +- .../drug/DrugShipmentEditorDrawer.test.ts | 1 + .../views/drug/DrugShipmentEditorDrawer.vue | 1 + .../fees/ContractFeeEditorDrawer.test.ts | 27 +- .../views/fees/ContractFeeEditorDrawer.vue | 427 ++++++----- frontend/src/views/ia/DrugShipments.test.ts | 1 + frontend/src/views/ia/DrugShipments.vue | 717 +++++++++++------- .../src/views/ia/MaterialEquipment.test.ts | 1 + frontend/src/views/ia/MaterialEquipment.vue | 526 +++++++++---- frontend/src/views/ia/ProjectOverview.vue | 32 +- frontend/src/views/ia/RiskIssuePd.vue | 201 +++-- .../src/views/ia/StartupFeasibilityEthics.vue | 246 ++++-- .../src/views/ia/StartupMeetingAuth.test.ts | 28 +- frontend/src/views/ia/StartupMeetingAuth.vue | 154 +++- frontend/src/views/ia/SubjectManagement.vue | 421 +++++----- .../MaterialEquipmentEditorDrawer.test.ts | 1 + .../MaterialEquipmentEditorDrawer.vue | 1 + frontend/src/views/overlayMounting.test.ts | 13 + frontend/src/views/startup/EthicsDetail.vue | 291 ++++--- .../src/views/startup/FeasibilityDetail.vue | 278 ++++--- frontend/src/views/startup/KickoffDetail.vue | 542 +++++++------ .../src/views/startup/KickoffEditorDrawer.vue | 4 +- frontend/src/views/startup/TrainingDetail.vue | 335 ++++++-- .../views/startup/TrainingEditorDrawer.vue | 1 - 25 files changed, 2744 insertions(+), 1516 deletions(-) diff --git a/backend/app/crud/overview.py b/backend/app/crud/overview.py index bf08dfe1..2ace7b71 100644 --- a/backend/app/crud/overview.py +++ b/backend/app/crud/overview.py @@ -1,6 +1,6 @@ """概览数据聚合CRUD操作""" import uuid -from datetime import datetime +from datetime import datetime, timezone from typing import Dict, List, Tuple from sqlalchemy import Date, cast, func, select, and_, or_ @@ -34,7 +34,7 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict: if not sites: return { "study_id": str(study_id), - "updated_at": datetime.now().isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "centers": [], "enrollment_by_month": [] } @@ -138,7 +138,7 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict: return { "study_id": str(study_id), - "updated_at": datetime.now().isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat(), "centers": centers, "enrollment_by_month": monthly_enrollment } diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 06285ba9..099345ca 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -631,7 +631,7 @@ export const TEXT = { listTitle: "AE/SAE 列表", emptyDescription: "暂无 AE/SAE 记录", subjectNo: "参与者编号", - toSubject: "查看参与者", + toSubject: "查看", }, riskIssuePd: { title: "PD", @@ -640,7 +640,7 @@ export const TEXT = { emptyDescription: "暂无 PD 记录", syncStatus: "同步状态", synced: "已同步", - toSubject: "查看参与者", + toSubject: "查看", }, riskIssueMonitoringVisits: { title: "监查访视问题", @@ -1028,7 +1028,6 @@ export const TEXT = { 失访: "失访", }, aeStatus: { - NEW: "新建", FOLLOW_UP: "随访中", CLOSED: "已关闭", }, diff --git a/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts b/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts index 9661349c..c55d710d 100644 --- a/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts +++ b/frontend/src/views/drug/DrugShipmentEditorDrawer.test.ts @@ -12,6 +12,7 @@ describe("DrugShipmentEditorDrawer attachments", () => { expect(source).toContain('ref="attachmentPanelRef"'); expect(source).toContain('entity-type="drug_shipment"'); expect(source).toContain(':mode="\'upload\'"'); + expect(source).toContain(':center-upload-card-content="true"'); expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)"); expect(source).toContain("uploadPending"); diff --git a/frontend/src/views/drug/DrugShipmentEditorDrawer.vue b/frontend/src/views/drug/DrugShipmentEditorDrawer.vue index 385b6676..37eb71d6 100644 --- a/frontend/src/views/drug/DrugShipmentEditorDrawer.vue +++ b/frontend/src/views/drug/DrugShipmentEditorDrawer.vue @@ -128,6 +128,7 @@ :entity-id="shipmentId" :mode="'upload'" :readonly="isFormReadOnly" + :center-upload-card-content="true" /> diff --git a/frontend/src/views/fees/ContractFeeEditorDrawer.test.ts b/frontend/src/views/fees/ContractFeeEditorDrawer.test.ts index 9daa4347..f6ba51f1 100644 --- a/frontend/src/views/fees/ContractFeeEditorDrawer.test.ts +++ b/frontend/src/views/fees/ContractFeeEditorDrawer.test.ts @@ -33,12 +33,26 @@ describe("ContractFeeEditorDrawer attachments", () => { it("shows attachment upload cards while creating a new contract fee", () => { const source = readDrawer(); - expect(source).toContain('class="form-group attachment-form-group"'); + expect(source).toContain('class="form-section"'); expect(source).toContain(':entity-id="form.id"'); expect(source).toContain("'); }); + it("uses centered three-column upload cards with contract-specific copy", () => { + const source = readDrawer(); + + expect(source).toContain('uploadText: "点击上传合同"'); + expect(source).toContain('uploadText: "点击上传凭证"'); + expect(source).toContain('uploadText: "点击上传发票"'); + expect(source).toContain(':center-upload-card-content="true"'); + expect(source).toContain(':upload-card-columns="3"'); + expect(source).not.toContain(':hide-upload-card-labels="true"'); + }); + it("labels the section as attachments instead of contract attachments", () => { const source = readLocale(); @@ -49,13 +63,12 @@ describe("ContractFeeEditorDrawer attachments", () => { it("uses a payment grid that keeps date pickers inside their columns", () => { const source = readDrawer(); - expect(source).toContain('class="payment-grid"'); - expect(source).toContain('class="payment-field payment-field--amount"'); - expect(source).toContain('class="payment-field payment-field--status"'); - expect(source).toContain('class="payment-field payment-field--remark"'); - expect(source).toContain("grid-template-columns: minmax(180px, 0.9fr) repeat(2, minmax(220px, 1fr));"); + expect(source).toContain('class="field-grid field-grid--3"'); + expect(source).toContain('class="status-row"'); + expect(source).toContain('class="status-datepicker"'); + expect(source).toContain("grid-template-columns: 1fr 1fr 1fr;"); expect(source).toContain("min-width: 0;"); - expect(source).toContain(".status-picker :deep(.el-date-editor.el-input)"); + expect(source).toContain(".status-datepicker :deep(.el-date-editor.el-input)"); }); it("uses ten-thousand yuan as the only amount unit and removes currency selection", () => { diff --git a/frontend/src/views/fees/ContractFeeEditorDrawer.vue b/frontend/src/views/fees/ContractFeeEditorDrawer.vue index 7c1d0c1d..b1640bd3 100644 --- a/frontend/src/views/fees/ContractFeeEditorDrawer.vue +++ b/frontend/src/views/fees/ContractFeeEditorDrawer.vue @@ -25,91 +25,83 @@ -
-
- + +
+
+ {{ TEXT.modules.feeContracts.contractInfoTitle }}
- - - - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + +
+ +
+ + + + + + + + + +
+ +
! 中心已停用,当前记录不可编辑
-
-
- - + +
+
+ + {{ TEXT.modules.feeContracts.paymentTitle }} - + {{ TEXT.common.actions.add }} @@ -120,25 +112,24 @@
-
-
-
- {{ index + 1 }} - {{ TEXT.modules.feeContracts.paymentSeqUnit }} +
+
+
+ {{ index + 1 }} + {{ TEXT.modules.feeContracts.paymentSeqUnit }}
- +
-
-
+ +
+
-
-
-
+
{{ TEXT.modules.feeContracts.isPaid }} @@ -149,15 +140,13 @@ value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isFormReadOnly" - class="status-picker" + class="status-datepicker" size="small" />
-
-
-
+
{{ TEXT.modules.feeContracts.isVerified }} @@ -168,18 +157,17 @@ value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" :disabled="isFormReadOnly" - class="status-picker" + class="status-datepicker" size="small" />
-
- - - -
+ + +
+
{{ paymentErrors[index].verification }}
@@ -187,9 +175,10 @@
-
-
- + +
+
+ {{ TEXT.modules.feeContracts.attachmentTitle }}
@@ -312,18 +303,23 @@ const drawerDirtyGuard = useDrawerDirtyGuard(() => ({ const selectedSiteInactive = computed(() => !!props.contractId && !!form.center_id && siteActiveMap.value[form.center_id] === false); const isFormReadOnly = computed(() => (props.contractId ? !canUpdate.value : !canCreate.value) || selectedSiteInactive.value); + + const attachmentGroups = [ { entityType: "contract_fee_contract", label: TEXT.modules.feeContracts.attachmentContract, + uploadText: "点击上传合同", }, { entityType: "contract_fee_voucher", label: TEXT.modules.feeContracts.attachmentVoucher, + uploadText: "点击上传凭证", }, { entityType: "contract_fee_invoice", label: TEXT.modules.feeContracts.attachmentInvoice, + uploadText: "点击上传发票", }, ]; @@ -558,13 +554,14 @@ watch( diff --git a/frontend/src/views/ia/DrugShipments.test.ts b/frontend/src/views/ia/DrugShipments.test.ts index 03324c5f..010a3745 100644 --- a/frontend/src/views/ia/DrugShipments.test.ts +++ b/frontend/src/views/ia/DrugShipments.test.ts @@ -43,6 +43,7 @@ describe("DrugShipments project permissions", () => { expect(source).toContain('entity-type="drug_shipment"'); expect(source).toContain(':entity-id="editingId"'); expect(source).toContain(':mode="\'upload\'"'); + expect(source).toContain(':center-upload-card-content="true"'); expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); expect(source).toContain("await attachmentPanelRef.value?.uploadPending(shipmentId)"); expect(source).toContain("uploadPending"); diff --git a/frontend/src/views/ia/DrugShipments.vue b/frontend/src/views/ia/DrugShipments.vue index 81df2bd2..9acbf286 100644 --- a/frontend/src/views/ia/DrugShipments.vue +++ b/frontend/src/views/ia/DrugShipments.vue @@ -1,10 +1,12 @@ @@ -271,6 +273,7 @@ import { computed, onMounted, reactive, ref, watch } from "vue"; import { useRouter } from "vue-router"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; +import { Plus } from "@element-plus/icons-vue"; import { useAuthStore } from "../../store/auth"; import { useStudyStore } from "../../store/study"; import { @@ -569,16 +572,11 @@ const onRowClick = (row: ShipmentRow) => { const statusType = (status: string) => { switch (status) { - case "PENDING": - return "info"; - case "IN_TRANSIT": - return "primary"; - case "SIGNED": - return "success"; - case "EXCEPTION": - return "danger"; - default: - return "info"; + case "PENDING": return "info"; + case "IN_TRANSIT": return "primary"; + case "SIGNED": return "success"; + case "EXCEPTION": return "danger"; + default: return "info"; } }; @@ -642,31 +640,260 @@ onMounted(async () => { @@ -854,7 +1050,8 @@ onMounted(async () => { background-color: #fff7d6 !important; } -.row-inactive .el-tag { +.row-inactive .status-pill, +.row-inactive .dir-tag { opacity: 0.6; } diff --git a/frontend/src/views/ia/MaterialEquipment.test.ts b/frontend/src/views/ia/MaterialEquipment.test.ts index ed265bbe..accc98b9 100644 --- a/frontend/src/views/ia/MaterialEquipment.test.ts +++ b/frontend/src/views/ia/MaterialEquipment.test.ts @@ -54,6 +54,7 @@ describe("MaterialEquipment project permissions", () => { expect(source).toContain('entity-type="material_equipment"'); expect(source).toContain(':entity-id="editingId"'); expect(source).toContain(':mode="\'upload\'"'); + expect(source).toContain(':center-upload-card-content="true"'); expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)"); expect(source).toContain("uploadPending"); diff --git a/frontend/src/views/ia/MaterialEquipment.vue b/frontend/src/views/ia/MaterialEquipment.vue index 05eebe21..6f60e57a 100644 --- a/frontend/src/views/ia/MaterialEquipment.vue +++ b/frontend/src/views/ia/MaterialEquipment.vue @@ -1,52 +1,87 @@ @@ -157,6 +177,7 @@ import { computed, reactive, ref, watch } from "vue"; import { useRouter } from "vue-router"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; +import { Plus } from "@element-plus/icons-vue"; import { createMaterialEquipment, deleteMaterialEquipment, @@ -398,31 +419,212 @@ watch( diff --git a/frontend/src/views/ia/ProjectOverview.vue b/frontend/src/views/ia/ProjectOverview.vue index 75cc6bab..3f3fc11c 100644 --- a/frontend/src/views/ia/ProjectOverview.vue +++ b/frontend/src/views/ia/ProjectOverview.vue @@ -10,7 +10,7 @@ 项目总览
- 更新于 {{ updatedAtLabel }} + {{ liveClock }} diff --git a/frontend/src/views/ia/StartupFeasibilityEthics.vue b/frontend/src/views/ia/StartupFeasibilityEthics.vue index d441c2c1..fe8bbd35 100644 --- a/frontend/src/views/ia/StartupFeasibilityEthics.vue +++ b/frontend/src/views/ia/StartupFeasibilityEthics.vue @@ -1,18 +1,9 @@ - + -
-
-
-
{{ TEXT.modules.startupFeasibilityEthics.ethicsTab }}
-
- - - {{ TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }} - -
-
{{ row.approval_no }} - + -
-
+
+ + + {{ TEXT.modules.startupFeasibilityEthics.feasibilityNewTitle }} + + + + {{ TEXT.modules.startupFeasibilityEthics.ethicsNewTitle }} + +
+
+
{ .page { display: flex; flex-direction: column; - gap: 0; + padding: 4px 0; + background: #eeeff1; } -.ctms-tab-content { - gap: 0; +.main-content-flat { + background: #ffffff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.03), 0 4px 12px rgba(0,0,0,0.03); } -.ctms-tab-content .ctms-section-header { - margin-bottom: 0; +/* ==================== Tabs Header ==================== */ +.tabs-header { + position: relative; +} + +.tabs-actions { + position: absolute; + top: 10px; + right: 20px; + display: flex; + gap: 8px; + z-index: 1; +} + +.tab-action-btn { + height: 28px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + padding: 0 10px; +} + +/* ==================== Tabs ==================== */ +.ctms-tabs :deep(.el-tabs__header) { + margin: 0; + padding: 0 24px; + background: transparent; + border-bottom: 1px solid #e8e8e8; +} +.ctms-tabs :deep(.el-tabs__nav-wrap::after) { display: none; } +.ctms-tabs :deep(.el-tabs__active-bar) { + background: #0a0a0a; + height: 2px; + bottom: -1px; +} +.ctms-tabs :deep(.el-tabs__item) { + font-size: 13px; + font-weight: 600; + color: #a3a3a3; + padding: 14px 20px; + transition: color 0.15s ease; +} +.ctms-tabs :deep(.el-tabs__item:hover) { color: #525252; } +.ctms-tabs :deep(.el-tabs__item.is-active) { color: #0a0a0a; } +.ctms-tabs :deep(.el-tabs__content) { padding: 0; } +.ctms-tabs :deep(.el-tab-pane) { padding: 0; } + +/* ==================== Table ==================== */ +.ctms-table { + --el-table-border-color: transparent; + --el-table-row-hover-bg-color: #f8f9fb; } .ctms-table :deep(.el-table__inner-wrapper::before) { display: none; } -.table-empty { - min-height: 220px; - display: flex; - align-items: center; - justify-content: center; - color: #8a97ab; +.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; - letter-spacing: 0.02em; +} + +.ctms-table :deep(.el-table__body tr) { + cursor: pointer; + transition: background 0.1s ease; +} + +.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; +} + +.table-empty { + min-height: 200px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: #a3a3a3; + font-size: 13px; + font-weight: 500; } diff --git a/frontend/src/views/ia/StartupMeetingAuth.test.ts b/frontend/src/views/ia/StartupMeetingAuth.test.ts index 1f561541..72ff1ae4 100644 --- a/frontend/src/views/ia/StartupMeetingAuth.test.ts +++ b/frontend/src/views/ia/StartupMeetingAuth.test.ts @@ -12,12 +12,32 @@ describe("StartupMeetingAuth permissions", () => { expect(source).toContain("if (canReadMembers.value)"); }); - it("does not create kickoff records from row click unless startup auth create permission is granted", () => { + it("does not lazily create kickoff records from row click or edit action", () => { const source = readSource(); - expect(source).toContain('can("startup.auth.create")'); - expect(source).toContain("canCreateAuth"); - expect(source).toContain("if (!canCreateAuth.value)"); + expect(source).not.toContain("createKickoff"); + expect(source).toContain("if (!row.meeting_id) return"); + }); + + it("renders rows from kickoff records instead of center placeholders", () => { + const source = readSource(); + + expect(source).toContain("const siteMap = computed"); + expect(source).toContain("return kickoffItems.value"); + expect(source).not.toContain("return sites.value"); + }); + + it("renders kickoff operation column with edit action only", () => { + const source = readSource(); + + expect(source).toContain(":label=\"TEXT.common.labels.actions\""); + expect(source).toContain('can("startup.auth.update")'); + expect(source).toContain("canUpdateAuth"); + expect(source).not.toContain("canDeleteAuth"); + expect(source).toContain("v-if=\"canUpdateAuth && scope.row.meeting_id\""); + expect(source).toContain("@click.stop=\"openKickoffEditor(scope.row)\""); + expect(source).not.toContain("removeKickoff"); + expect(source).toContain(" { diff --git a/frontend/src/views/ia/StartupMeetingAuth.vue b/frontend/src/views/ia/StartupMeetingAuth.vue index 0658cf93..4ab83e02 100644 --- a/frontend/src/views/ia/StartupMeetingAuth.vue +++ b/frontend/src/views/ia/StartupMeetingAuth.vue @@ -25,12 +25,37 @@ + + +
+
@@ -40,7 +65,7 @@ import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { useAuthStore } from "../../store/auth"; import { useStudyStore } from "../../store/study"; -import { createKickoff, listKickoffs } from "../../api/startup"; +import { listKickoffs } from "../../api/startup"; import { fetchSites } from "../../api/sites"; import { listMembers } from "../../api/members"; import { fetchUsers } from "../../api/users"; @@ -48,6 +73,7 @@ import { displayDate } from "../../utils/display"; import { isSystemAdmin } from "../../utils/roles"; import { TEXT } from "../../locales"; import { usePermission } from "../../utils/permission"; +import KickoffEditorDrawer from "../startup/KickoffEditorDrawer.vue"; const router = useRouter(); const auth = useAuthStore(); @@ -58,9 +84,14 @@ const sites = ref([]); const users = ref([]); const members = ref([]); const loading = ref(false); +const editorVisible = ref(false); +const editingMeetingId = ref(""); +const editingSiteName = ref(""); +const editingOwnerLabel = ref(""); +const editingReadOnly = ref(false); const isAdmin = computed(() => isSystemAdmin(auth.user)); const canReadMembers = computed(() => can("project.members.list")); -const canCreateAuth = computed(() => can("startup.auth.create")); +const canUpdateAuth = computed(() => can("startup.auth.update")); const memberNameMap = computed(() => { const map: Record = {}; @@ -76,10 +107,10 @@ const memberNameMap = computed(() => { return map; }); -const siteActiveMap = computed(() => { - const map: Record = {}; +const siteMap = computed(() => { + const map: Record = {}; sites.value.forEach((site) => { - if (site?.id) map[site.id] = !!site.is_active; + if (site?.id) map[site.id] = site; }); return map; }); @@ -96,22 +127,18 @@ const contactLabel = (row: any) => { }; const kickoffRows = computed(() => { - const kickoffMap = kickoffItems.value.reduce>((acc, item) => { - if (item.site_id) acc[item.site_id] = item; - return acc; - }, {}); - return sites.value - .map((site) => { - const meeting = kickoffMap[site.id]; + return kickoffItems.value + .map((meeting) => { + const site = siteMap.value[meeting.site_id] || {}; return { - site_id: site.id, + site_id: meeting.site_id, site_name: site.name || TEXT.common.fallback, contact: site.contact, contact_display: site.contact_display, - kickoff_date: meeting?.kickoff_date || null, - meeting_id: meeting?.id, - status: meeting?.kickoff_date ? "COMPLETED" : "PENDING", - is_active: !!site.is_active, + kickoff_date: meeting.kickoff_date || null, + meeting_id: meeting.id, + status: meeting.kickoff_date ? "COMPLETED" : "PENDING", + is_active: site.is_active !== false, }; }) .sort((a, b) => Number(a.is_active === false) - Number(b.is_active === false)); @@ -147,27 +174,40 @@ const loadKickoffs = async () => { }; const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`); +const prepareKickoffEditor = (row: any, meetingId: string) => { + editingMeetingId.value = meetingId; + editingSiteName.value = row.site_name || TEXT.common.fallback; + editingOwnerLabel.value = contactLabel(row); + editingReadOnly.value = row.is_active === false; + editorVisible.value = true; +}; + const onKickoffRowClick = async (row: any) => { - const studyId = study.currentStudy?.id; - if (!studyId || !row?.site_id) return; - if (row.is_active === false && !row.meeting_id) { - ElMessage.warning("中心已停用"); - return; - } + if (!row?.site_id) return; + if (!row.meeting_id) return; if (row.meeting_id) { goKickoffDetail(row.meeting_id); return; } - if (!canCreateAuth.value) { +}; + +const openKickoffEditor = async (row: any) => { + const studyId = study.currentStudy?.id; + if (!studyId || !row?.site_id || !row.meeting_id) return; + if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; } - try { - const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any; - goKickoffDetail(data.id); - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed); + if (row.is_active === false) { + ElMessage.warning("中心已停用"); + return; } + prepareKickoffEditor(row, row.meeting_id); +}; + +const handleEditorSaved = async () => { + editorVisible.value = false; + await loadKickoffs(); }; const kickoffRowClass = ({ row }: { row: any }) => (row?.is_active === false ? "row-inactive" : ""); @@ -183,10 +223,32 @@ onMounted(() => { gap: 0; } +.kickoff-table-section { + background: #ffffff; + overflow: hidden; +} + .kickoff-table :deep(.el-table__inner-wrapper::before) { display: none; } +.cell-actions { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.cell-actions .el-button { + font-size: 13px; + padding: 0; + height: auto; +} + +.cell-actions .el-button + .el-button { + margin-left: 0; +} + .table-empty { min-height: 220px; display: flex; @@ -197,6 +259,40 @@ onMounted(() => { font-weight: 500; letter-spacing: 0.02em; } + +/* ==================== Table header (文件版本管理 style) ==================== */ +.kickoff-table { + --el-table-border-color: transparent; + --el-table-row-hover-bg-color: #f8f9fb; +} + +.kickoff-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; +} + +.kickoff-table :deep(td.el-table__cell) { + padding: 12px 14px; + color: #0a0a0a; + font-size: 14px; + font-weight: 500; +} + +.kickoff-table :deep(.el-table__body tr) { + cursor: pointer; + transition: background 0.1s ease; +} + +.kickoff-table :deep(.el-tag) { + font-weight: 600; + border: none; +} @@ -413,7 +431,8 @@ onMounted(async () => { background-color: #fff7d6 !important; } -.row-inactive .el-tag { +.row-inactive .el-tag, +.row-inactive .ae-badge { opacity: 0.6; } diff --git a/frontend/src/views/materials/MaterialEquipmentEditorDrawer.test.ts b/frontend/src/views/materials/MaterialEquipmentEditorDrawer.test.ts index f6fb666c..3e1eef6c 100644 --- a/frontend/src/views/materials/MaterialEquipmentEditorDrawer.test.ts +++ b/frontend/src/views/materials/MaterialEquipmentEditorDrawer.test.ts @@ -16,6 +16,7 @@ describe("MaterialEquipmentEditorDrawer attachments", () => { expect(source).toContain('ref="attachmentPanelRef"'); expect(source).toContain('entity-type="material_equipment"'); expect(source).toContain(':mode="\'upload\'"'); + expect(source).toContain(':center-upload-card-content="true"'); expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []"); expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.equipmentId)"); expect(source).toContain("uploadPending"); diff --git a/frontend/src/views/materials/MaterialEquipmentEditorDrawer.vue b/frontend/src/views/materials/MaterialEquipmentEditorDrawer.vue index 06e7e0eb..e6061265 100644 --- a/frontend/src/views/materials/MaterialEquipmentEditorDrawer.vue +++ b/frontend/src/views/materials/MaterialEquipmentEditorDrawer.vue @@ -88,6 +88,7 @@ :entity-id="equipmentId" :mode="'upload'" :readonly="readonly" + :center-upload-card-content="true" />
diff --git a/frontend/src/views/overlayMounting.test.ts b/frontend/src/views/overlayMounting.test.ts index 1cff00be..4214a5a0 100644 --- a/frontend/src/views/overlayMounting.test.ts +++ b/frontend/src/views/overlayMounting.test.ts @@ -111,6 +111,19 @@ describe("route view overlays are mounted only when visible", () => { file: "./admin/PermissionManagement.vue", snippets: [':close-on-click-modal="true"', ':before-close="handleTemplateDrawerBeforeClose"'], }, + { + file: "./documents/DocumentList.vue", + snippets: [':close-on-click-modal="true"', ':before-close="editorDirtyGuard.beforeClose"'], + }, + { + file: "./documents/DocumentDetail.vue", + snippets: [ + ':close-on-click-modal="true"', + ':before-close="editorDirtyGuard.beforeClose"', + ':before-close="uploadDirtyGuard.beforeClose"', + ':before-close="distributeDirtyGuard.beforeClose"', + ], + }, { file: "./admin/AuditLogs.vue", snippets: [':close-on-click-modal="true"'], diff --git a/frontend/src/views/startup/EthicsDetail.vue b/frontend/src/views/startup/EthicsDetail.vue index 4f691702..2a6b9a90 100644 --- a/frontend/src/views/startup/EthicsDetail.vue +++ b/frontend/src/views/startup/EthicsDetail.vue @@ -1,38 +1,110 @@