feat(documents): refresh detail pages and attachment cards

This commit is contained in:
Cheng Zhou
2026-06-08 11:03:34 +08:00
parent a3f6a04f35
commit 834b4f1d48
11 changed files with 2372 additions and 929 deletions
+3
View File
@@ -5,6 +5,8 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from app.schemas.user import UserDisplay
class DocumentVersionStatus(str, enum.Enum): class DocumentVersionStatus(str, enum.Enum):
DRAFT = "DRAFT" DRAFT = "DRAFT"
@@ -39,6 +41,7 @@ class DocumentVersionRead(BaseModel):
mime_type: Optional[str] = None mime_type: Optional[str] = None
change_summary: Optional[str] = None change_summary: Optional[str] = None
created_by: Optional[uuid.UUID] = None created_by: Optional[uuid.UUID] = None
created_by_user: Optional[UserDisplay] = None
submitted_at: Optional[datetime] = None submitted_at: Optional[datetime] = None
approved_at: Optional[datetime] = None approved_at: Optional[datetime] = None
withdrawn_at: Optional[datetime] = None withdrawn_at: Optional[datetime] = None
+10 -1
View File
@@ -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): async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_user, action: str):
study = await study_crud.get(db, trial_id) study = await study_crud.get(db, trial_id)
if not study: if not study:
@@ -242,6 +249,8 @@ async def get_document_detail(
owner_obj = await user_crud.get_by_id(db, doc.owner_id) owner_obj = await user_crud.get_by_id(db, doc.owner_id)
if owner_obj: if owner_obj:
owner = UserDisplay.model_validate(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( return DocumentDetail(
id=doc.id, id=doc.id,
trial_id=doc.trial_id, trial_id=doc.trial_id,
@@ -257,7 +266,7 @@ async def get_document_detail(
created_at=doc.created_at, created_at=doc.created_at,
updated_at=doc.updated_at, updated_at=doc.updated_at,
owner=owner, 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, distribution_stats=distribution_stats,
) )
@@ -55,19 +55,28 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</template> </template>
<div v-else class="upload-grid"> <div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-card"> <div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
<el-upload <el-upload
class="attachment-card-upload"
:show-file-list="false" :show-file-list="false"
multiple multiple
:disabled="!canUploadGroup(group) || isUploading(group.key)" :disabled="!canUploadGroup(group) || isUploading(group.key)"
:auto-upload="false" :auto-upload="false"
:on-change="(file: any) => queuePendingUpload(group.key, file)" :on-change="(file: any) => queuePendingUpload(group.key, file)"
> >
<div class="upload-trigger" :class="{ 'is-disabled': !canUploadGroup(group) }"> <div
<el-icon class="upload-icon"><Document /></el-icon> class="attachment-upload-card"
<span class="upload-text">点击上传文件</span> :class="{
'is-disabled': !canUploadGroup(group),
'attachment-upload-card--centered': centerUploadCardContent,
}"
>
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
<div class="upload-trigger">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<span class="upload-text">{{ group.uploadText || "点击上传文件" }}</span>
</div>
</div> </div>
</el-upload> </el-upload>
<div v-if="pendingMap[group.key]?.length" class="pending-list"> <div v-if="pendingMap[group.key]?.length" class="pending-list">
@@ -112,7 +121,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Document } from "@element-plus/icons-vue"; import { UploadFilled } from "@element-plus/icons-vue";
import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments"; import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments";
import { formatFileSize } from "./attachmentUtils"; import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
@@ -126,12 +135,14 @@ import { TEXT } from "../../locales";
type AttachmentEntityGroup = { type AttachmentEntityGroup = {
entityType: string; entityType: string;
label: string; label: string;
uploadText?: string;
}; };
type UploadGroup = { type UploadGroup = {
key: string; key: string;
label: string; label: string;
entityType: string; entityType: string;
uploadText?: string;
}; };
type PendingUploadStatus = "pending" | "uploading" | "failed"; type PendingUploadStatus = "pending" | "uploading" | "failed";
@@ -153,6 +164,9 @@ const props = defineProps<{
mode?: "table" | "upload"; mode?: "table" | "upload";
maxSizeMb?: number; maxSizeMb?: number;
refreshKey?: number; refreshKey?: number;
hideUploadCardLabels?: boolean;
centerUploadCardContent?: boolean;
uploadCardColumns?: 2 | 3;
}>(); }>();
const attachments = ref<any[]>([]); const attachments = ref<any[]>([]);
@@ -178,11 +192,13 @@ const uploadGroups = computed<UploadGroup[]>(() => {
key: group.entityType, key: group.entityType,
label: group.label, label: group.label,
entityType: group.entityType, entityType: group.entityType,
uploadText: group.uploadText,
})); }));
} }
return [{ key: props.entityType, label: headerTitle.value, entityType: props.entityType }]; return [{ key: props.entityType, label: headerTitle.value, entityType: props.entityType }];
}); });
const showUploadCardLabels = computed(() => uploadGroups.value.length > 1); const showUploadCardLabels = computed(() => !props.hideUploadCardLabels && uploadGroups.value.length > 1);
const uploadCardColumns = computed(() => props.uploadCardColumns || 2);
const currentRolePermissions = computed(() => { const currentRolePermissions = computed(() => {
const role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study; const role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
@@ -494,20 +510,64 @@ defineExpose({
gap: 16px; gap: 16px;
} }
.upload-grid--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.attachment-upload-item {
min-width: 0;
}
.attachment-card-upload,
.attachment-card-upload :deep(.el-upload) {
display: block;
width: 100%;
}
.attachment-upload-card { .attachment-upload-card {
min-height: 72px; min-height: 72px;
padding: 14px 16px; padding: 14px 16px;
border: 1px dashed #d0dced; border: 1px dashed #d0dced;
border-radius: 8px; border-radius: 8px;
background: #ffffff; background: #ffffff;
transition: border-color 0.2s ease, background-color 0.2s ease; cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
} }
.attachment-upload-card:hover { .attachment-upload-card:hover,
.attachment-upload-card:focus-within {
border-color: var(--ctms-primary); border-color: var(--ctms-primary);
background: #f8faff; background: #f8faff;
} }
.attachment-upload-card.is-disabled {
cursor: not-allowed;
opacity: 0.62;
}
.attachment-upload-card--centered {
min-height: 112px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.attachment-upload-card--centered .upload-card-label {
align-self: flex-start;
margin-bottom: 0;
text-align: left;
}
.attachment-upload-card--centered .upload-trigger {
flex: 1;
flex-direction: column;
justify-content: center;
gap: 10px;
width: 100%;
}
.upload-card-label { .upload-card-label {
margin-bottom: 10px; margin-bottom: 10px;
color: #4a6283; color: #4a6283;
@@ -520,25 +580,22 @@ defineExpose({
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-height: 34px; min-height: 34px;
cursor: pointer;
}
.upload-trigger.is-disabled {
cursor: not-allowed;
opacity: 0.62;
} }
.upload-icon { .upload-icon {
font-size: 18px; font-size: 22px;
line-height: 1; line-height: 1;
color: #314a6b;
} }
.upload-text { .upload-text {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-size: 13px; font-size: 13px;
line-height: 1.4;
} }
.upload-trigger:not(.is-disabled):hover .upload-text { .attachment-upload-card:not(.is-disabled):hover .upload-text,
.attachment-upload-card:not(.is-disabled):hover .upload-icon {
color: var(--ctms-primary); color: var(--ctms-primary);
} }
+6
View File
@@ -45,6 +45,12 @@ export interface DocumentVersion {
mime_type?: string | null; mime_type?: string | null;
change_summary?: string | null; change_summary?: string | null;
created_by?: string | null; created_by?: string | null;
created_by_user?: {
id: string;
email: string;
full_name: string;
avatar_url?: string | null;
} | null;
submitted_at?: string | null; submitted_at?: string | null;
approved_at?: string | null; approved_at?: string | null;
withdrawn_at?: string | null; withdrawn_at?: string | null;
@@ -50,6 +50,38 @@ describe("DocumentDetail permissions", () => {
expect(source).toContain('can("project.members.list")'); expect(source).toContain('can("project.members.list")');
expect(source).toContain("if (!canReadMembers.value)"); expect(source).toContain("if (!canReadMembers.value)");
}); });
it("reuses attachment-style preview for document versions", () => {
const source = readSource();
expect(source).toContain("@click=\"previewVersion(row)\"");
expect(source).toContain("TEXT.common.labels.preview");
expect(source).toContain('return "image"');
expect(source).toContain('return "pdf"');
expect(source).toContain("TEXT.common.messages.previewNotSupported");
expect(source).toContain("URL.createObjectURL(blob)");
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
});
it("prefers backend creator display data instead of exposing creator ids", () => {
const source = readSource();
expect(source).toContain("displayCreator(row.created_by_user, row.created_by)");
expect(source).toContain("const displayCreator = (user?: UserInfo | null, userId?: string | null)");
expect(source).toContain("getUserDisplayName(user)");
expect(source).not.toContain("displayCreator(row.created_by)");
expect(source).not.toContain("return userMap.value[userId] || userId;");
});
it("uses the version record creation time for the updated-at column", () => {
const source = readSource();
expect(source).toContain("displayDateTime");
expect(source).toContain("splitDateTime(row.created_at).date");
expect(source).toContain("splitDateTime(row.created_at).time");
expect(source).toContain("const displayValue = displayDateTime(value)");
expect(source).not.toContain("splitDateTime(row.effective_at || row.created_at)");
});
}); });
describe("DocumentDetail breadcrumbs", () => { describe("DocumentDetail breadcrumbs", () => {
@@ -76,4 +108,19 @@ describe("DocumentDetail editor overlays", () => {
expect(source).not.toContain("<el-dialog append-to=\".layout-main .content-wrapper\" v-model=\"uploadVisible\""); expect(source).not.toContain("<el-dialog append-to=\".layout-main .content-wrapper\" v-model=\"uploadVisible\"");
expect(source).not.toContain("<el-dialog append-to=\".layout-main .content-wrapper\" v-model=\"distributeVisible\""); expect(source).not.toContain("<el-dialog append-to=\".layout-main .content-wrapper\" v-model=\"distributeVisible\"");
}); });
it("guards dirty drawer closes while allowing mask close", () => {
const source = readSource();
expect(source).toContain("useDrawerDirtyGuard");
expect(source).toContain("const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);");
expect(source).toContain("const uploadDirtyGuard = useDrawerDirtyGuard(() => ({");
expect(source).toContain("const distributeDirtyGuard = useDrawerDirtyGuard(() => distributeForm);");
expect(source).toContain(':before-close="editorDirtyGuard.beforeClose"');
expect(source).toContain(':before-close="uploadDirtyGuard.beforeClose"');
expect(source).toContain(':before-close="distributeDirtyGuard.beforeClose"');
expect(source).toContain("editorDirtyGuard.syncBaseline()");
expect(source).toContain("uploadDirtyGuard.syncBaseline()");
expect(source).toContain("distributeDirtyGuard.syncBaseline()");
});
}); });
File diff suppressed because it is too large Load Diff
@@ -28,4 +28,24 @@ describe("DocumentList permissions", () => {
expect(source).not.toContain("<el-dialog"); expect(source).not.toContain("<el-dialog");
expect(source).not.toContain('append-to=".layout-main .content-wrapper"'); expect(source).not.toContain('append-to=".layout-main .content-wrapper"');
}); });
it("guards dirty drawer closes while allowing mask close", () => {
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", "")');
});
}); });
+106 -12
View File
@@ -70,17 +70,22 @@
</el-table-column> </el-table-column>
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip> <el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span> <span class="text-secondary datetime-stack">
<span>{{ splitDateTime(row.updated_at).date }}</span>
<span class="datetime-stack__time">{{ splitDateTime(row.updated_at).time }}</span>
</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.modules.fileVersionManagement.columns.actions" width="140" align="center"> <el-table-column v-if="canUpdate || canDelete" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="canUpdate" link type="primary" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)"> <div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
<el-button v-if="canDelete" link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)"> <el-button v-if="canDelete" link type="danger" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }} {{ TEXT.common.actions.delete }}
</el-button> </el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
@@ -96,6 +101,7 @@
direction="rtl" direction="rtl"
size="620px" size="620px"
:close-on-click-modal="true" :close-on-click-modal="true"
:before-close="editorDirtyGuard.beforeClose"
:show-close="false" :show-close="false"
destroy-on-close destroy-on-close
class="document-editor-drawer" class="document-editor-drawer"
@@ -167,9 +173,10 @@ import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import type { DocumentSummary } from "../../types/documents"; import type { DocumentSummary } from "../../types/documents";
import type { Site } from "../../types/api"; import type { Site } from "../../types/api";
import { displayEnum, displayText } from "../../utils/display"; import { displayDateTime, displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
const route = useRoute(); const route = useRoute();
@@ -239,6 +246,7 @@ const editorForm = reactive({
site_id: "", site_id: "",
doc_type: "", doc_type: "",
}); });
const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const editorRules: FormRules = { const editorRules: FormRules = {
doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }], doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
@@ -318,6 +326,7 @@ const openCreate = () => {
} }
editingDocumentId.value = ""; editingDocumentId.value = "";
resetEditorForm(); resetEditorForm();
editorDirtyGuard.syncBaseline();
editorVisible.value = true; editorVisible.value = true;
}; };
@@ -339,6 +348,7 @@ const openEdit = (row: DocumentSummary) => {
doc_type: row.doc_type || "", doc_type: row.doc_type || "",
}); });
editorFormRef.value?.clearValidate(); editorFormRef.value?.clearValidate();
editorDirtyGuard.syncBaseline();
editorVisible.value = true; editorVisible.value = true;
}; };
@@ -416,9 +426,10 @@ const confirmDelete = async (row: DocumentSummary) => {
} }
}; };
const formatDate = (value?: string | null) => { const splitDateTime = (value?: string | null) => {
if (!value) return TEXT.common.fallback; const displayValue = displayDateTime(value);
return value.replace("T", " ").replace("Z", "").split(".")[0]; const [date, time] = displayValue.split(" ");
return { date, time: time || "" };
}; };
onMounted(() => { onMounted(() => {
@@ -461,10 +472,6 @@ watch(
width: 132px; width: 132px;
} }
.ctms-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.filter-spacer { .filter-spacer {
flex: 1; flex: 1;
} }
@@ -524,6 +531,93 @@ watch(
gap: 10px; 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;
}
</style> </style>
<style> <style>
+443 -75
View File
@@ -1,50 +1,126 @@
<template> <template>
<div class="page"> <div class="page">
<div class="unified-shell"> <StateError v-if="errorMessage" :description="errorMessage">
<section v-loading="loading" class="unified-section"> <template #action>
<div class="unified-section-header"> <el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
<div> </template>
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div> </StateError>
<div class="detail-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</div>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<!-- ==================== Hero ==================== -->
<div class="hero-banner">
<div class="hero-bg-pattern" />
<div class="hero-content">
<div class="hero-top">
<div class="hero-title-group">
<h2 class="hero-title">{{ TEXT.modules.drugShipments.detailTitle }}</h2>
<span :class="['hero-status-badge', `hero-status-badge--${statusType(detail.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
</span>
</div> </div>
<div class="actions"> <el-button v-if="canUpdateShipment" type="primary" :disabled="isReadOnly" @click="goEdit" class="hero-edit-btn">
<el-button v-if="canUpdateShipment" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn">
<el-icon class="el-icon--left"><Edit /></el-icon> <el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
</div> </div>
<div class="hero-stats">
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--site">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.site }}</span>
<span class="stat-value">{{ detail.site_name || TEXT.common.fallback }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap" :class="detail.direction === 'SEND' ? 'stat-icon-wrap--send' : 'stat-icon-wrap--return'">
<svg v-if="detail.direction === 'SEND'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.direction }}</span>
<span class="stat-value stat-value--direction">{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--qty">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.quantity }}</span>
<span class="stat-value">{{ detail.quantity ?? TEXT.common.fallback }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--batch">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.batchNo }}</span>
<span class="stat-value stat-value--mono">{{ detail.batch_no || TEXT.common.fallback }}</span>
</div>
</div>
</div>
</div>
</div> </div>
<div class="section-title-row"> <!-- ==================== 运输记录 ==================== -->
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span> <div class="section-card">
<el-tag :type="statusType(detail.status)" effect="plain" round> <div class="section-card-header">
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }} <div class="section-card-header-left">
</el-tag> <span class="accent-dot accent-dot--blue" />
<span class="section-card-title">{{ TEXT.modules.drugShipments.recordLabel }}</span>
</div>
<div v-if="detail.tracking_no" class="badge">{{ detail.tracking_no }}</div>
</div>
<div class="section-divider" />
<div class="section-card-body">
<div class="stat-grid">
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.trackingNo }}</span>
<span class="stat-tile-value stat-tile-value--mono">{{ detail.tracking_no || TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.carrier }}</span>
<span class="stat-tile-value">{{ detail.carrier || TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.shipDate }}</span>
<span class="stat-tile-value">{{ displayDate(detail.ship_date) }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.receiveDate }}</span>
<span class="stat-tile-value">{{ displayDate(detail.receive_date) }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.quantity }}</span>
<span class="stat-tile-value">{{ detail.quantity ?? TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.batchNo }}</span>
<span class="stat-tile-value stat-tile-value--mono">{{ detail.batch_no || TEXT.common.fallback }}</span>
</div>
<div class="stat-tile stat-tile--span2">
<span class="stat-tile-label">{{ TEXT.common.fields.remark }}</span>
<span class="stat-tile-value stat-tile-value--muted">{{ detail.remark || TEXT.common.fallback }}</span>
</div>
</div>
</div>
</div> </div>
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.direction">
<el-tag :class="['type-tag', detail.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section"> <!-- ==================== 附件 ==================== -->
<div class="ctms-section-title section-title-inline">{{ TEXT.modules.drugShipments.recordLabel }}</div> <div class="section-card">
<el-descriptions :column="2" border> <div class="section-card-header">
<el-descriptions-item :label="TEXT.common.fields.trackingNo">{{ detail.tracking_no || TEXT.common.fallback }}</el-descriptions-item> <div class="section-card-header-left">
<el-descriptions-item :label="TEXT.common.fields.carrier">{{ detail.carrier || TEXT.common.fallback }}</el-descriptions-item> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#22c55e;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<el-descriptions-item :label="TEXT.common.fields.shipDate">{{ displayDate(detail.ship_date) }}</el-descriptions-item> <span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
<el-descriptions-item :label="TEXT.common.fields.receiveDate">{{ displayDate(detail.receive_date) }}</el-descriptions-item> </div>
<el-descriptions-item :label="TEXT.common.fields.quantity">{{ detail.quantity ?? TEXT.common.fallback }}</el-descriptions-item> </div>
<el-descriptions-item :label="TEXT.common.fields.batchNo">{{ detail.batch_no || TEXT.common.fallback }}</el-descriptions-item> <div class="section-divider" />
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item> <div class="section-card-body">
</el-descriptions>
</section>
<section class="unified-section attachment-section">
<AttachmentList <AttachmentList
v-if="shipmentId && studyId" v-if="shipmentId && studyId"
:study-id="studyId" :study-id="studyId"
@@ -53,8 +129,10 @@
:readonly="isReadOnly" :readonly="isReadOnly"
:hide-uploader="true" :hide-uploader="true"
/> />
</section>
</div> </div>
</div>
</template>
<DrugShipmentEditorDrawer <DrugShipmentEditorDrawer
v-model="editorVisible" v-model="editorVisible"
:shipment-id="shipmentId || ''" :shipment-id="shipmentId || ''"
@@ -76,6 +154,8 @@ import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import DrugShipmentEditorDrawer from "./DrugShipmentEditorDrawer.vue"; import DrugShipmentEditorDrawer from "./DrugShipmentEditorDrawer.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles"; import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -84,6 +164,7 @@ const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false); const editorVisible = ref(false);
const shipmentId = computed(() => route.params.shipmentId as string | undefined); const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const studyId = computed(() => study.currentStudy?.id || ""); const studyId = computed(() => study.currentStudy?.id || "");
@@ -129,6 +210,7 @@ const loadSites = async () => {
const load = async () => { const load = async () => {
if (!studyId.value || !shipmentId.value) return; if (!studyId.value || !shipmentId.value) return;
loading.value = true; loading.value = true;
errorMessage.value = "";
try { try {
const { data } = await getDrugShipment(studyId.value, shipmentId.value); const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(detail, { Object.assign(detail, {
@@ -150,7 +232,7 @@ const load = async () => {
pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle, pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle,
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed); errorMessage.value = e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -174,16 +256,11 @@ const handleEditorSaved = () => {
const statusType = (status: string) => { const statusType = (status: string) => {
switch (status) { switch (status) {
case "PENDING": case "PENDING": return "info";
return "info"; case "IN_TRANSIT": return "primary";
case "IN_TRANSIT": case "SIGNED": return "success";
return "primary"; case "EXCEPTION": return "danger";
case "SIGNED": default: return "info";
return "success";
case "EXCEPTION":
return "danger";
default:
return "info";
} }
}; };
@@ -202,56 +279,347 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
/* ==================== Base ==================== */
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 16px;
}
.detail-subtitle {
margin-top: 4px;
color: var(--ctms-text-secondary);
font-size: 13px;
}
.attachment-section {
padding: 0; padding: 0;
background: transparent;
} }
.actions { /* ==================== Hero Banner ==================== */
.hero-banner {
position: relative;
background: #fff7ee;
border: 1px solid rgba(180, 110, 50, 0.10);
border-radius: 8px;
padding: 28px 32px 24px;
overflow: hidden;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.7) inset,
0 1px 2px rgba(180, 110, 50, 0.04);
}
.hero-bg-pattern { display: none; }
.hero-content {
position: relative;
z-index: 1;
}
.hero-top {
display: flex; display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.hero-title-group {
display: flex;
align-items: center;
gap: 12px; gap: 12px;
} }
.header-action-btn { .hero-title {
height: 36px; margin: 0;
border-radius: 8px; font-size: 19px;
padding: 0 16px; font-weight: 600;
color: #3d2410;
letter-spacing: 0.005em;
} }
.section-title-row { .hero-status-badge {
display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 11px;
font-weight: 500;
border-radius: 20px;
letter-spacing: 0.02em;
backdrop-filter: blur(8px);
}
.hero-status-badge--info {
background: rgba(255, 255, 255, 0.55);
color: rgba(80, 45, 20, 0.55);
border: 1px solid rgba(180, 110, 50, 0.12);
}
.hero-status-badge--primary {
background: rgba(255, 255, 255, 0.55);
color: rgba(80, 45, 20, 0.78);
border: 1px solid rgba(180, 110, 50, 0.18);
}
.hero-status-badge--success {
background: rgba(255, 255, 255, 0.55);
color: rgba(80, 45, 20, 0.78);
border: 1px solid rgba(180, 110, 50, 0.18);
}
.hero-status-badge--danger {
background: rgba(255, 240, 240, 0.70);
color: rgba(160, 40, 40, 0.75);
border: 1px solid rgba(200, 80, 80, 0.18);
}
.hero-edit-btn {
height: 34px;
border-radius: 6px;
padding: 0 18px;
font-weight: 500;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(180, 110, 50, 0.18);
color: #3d2410;
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
transition: all 0.2s ease;
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 1px 2px rgba(180, 110, 50, 0.06);
}
.hero-edit-btn:hover {
background: #ffffff;
border-color: rgba(180, 110, 50, 0.30);
color: #3d2410;
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 4px 12px rgba(212, 130, 60, 0.14);
}
.hero-edit-btn:active {
transform: scale(0.97);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.hero-stat {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.62) 100%);
border: 1px solid rgba(180, 110, 50, 0.10);
border-radius: 6px;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
transition: all 0.25s ease;
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 1px 2px rgba(180, 110, 50, 0.04);
}
.hero-stat:hover {
background: linear-gradient(180deg, #ffffff 0%, rgba(255, 255, 255, 0.92) 100%);
border-color: rgba(180, 110, 50, 0.18);
transform: translateY(-1px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 6px 16px rgba(212, 130, 60, 0.10);
}
.stat-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
flex-shrink: 0;
border: 1px solid rgba(180, 110, 50, 0.06);
}
.stat-icon-wrap--site { background: rgba(212, 145, 80, 0.14); color: #b06b30; }
.stat-icon-wrap--send { background: rgba(220, 160, 90, 0.18); color: #b87532; }
.stat-icon-wrap--return { background: rgba(168, 158, 80, 0.16); color: #8a7028; }
.stat-icon-wrap--qty { background: rgba(196, 116, 92, 0.14); color: #a85838; }
.stat-icon-wrap--batch { background: rgba(196, 100, 100, 0.14); color: #aa4444; }
.stat-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.stat-label {
font-size: 11px;
font-weight: 500;
color: rgba(80, 45, 20, 0.50);
letter-spacing: 0.03em;
text-transform: uppercase;
}
.stat-value {
font-size: 14px;
font-weight: 600;
color: #3d2410;
word-break: break-all;
}
.stat-value--direction {
font-weight: 600;
}
.stat-value--mono {
font-family: ui-monospace, SFMono-Regular, monospace;
letter-spacing: 0.01em;
}
/* ==================== Section Cards ==================== */
.section-card {
background: #ffffff;
border-radius: 6px;
overflow: hidden;
box-shadow: none;
}
.section-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; padding: 18px 24px 0;
} }
.section-title-inline { .section-card-header-left {
margin-bottom: 12px; display: flex;
align-items: center;
gap: 10px;
} }
.type-tag { .section-card-title {
font-size: 14px;
font-weight: 600; font-weight: 600;
border: none; color: #0a0a0a;
letter-spacing: -0.01em;
} }
.type-send { .badge {
background-color: #fff7ed !important; padding: 2px 10px;
color: #ea580c !important; font-size: 11px;
font-weight: 500;
color: #525252;
background: #f5f5f5;
border-radius: 4px;
letter-spacing: 0.01em;
} }
.type-return { .section-divider {
background-color: #fefce8 !important; display: none;
color: #ca8a04 !important; }
.section-card-body {
padding: 20px 24px 24px;
}
/* ==================== Accent Dots ==================== */
.accent-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.accent-dot--blue { background: #3b82f6; }
.accent-dot--green { background: #22c55e; }
/* ==================== Stat Grid ==================== */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.stat-tile {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
background: #fafbfc;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.15s ease;
}
.stat-tile:hover {
background: #f5f6f8;
border-color: #e5e5e5;
}
.stat-tile--span2 {
grid-column: span 2;
}
.stat-tile-label {
font-size: 11px;
font-weight: 500;
color: #8a8a8a;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.stat-tile-value {
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
word-break: break-all;
}
.stat-tile-value--mono {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
}
.stat-tile-value--muted {
font-weight: 500;
color: #525252;
}
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.hero-stats {
grid-template-columns: repeat(2, 1fr);
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.hero-banner {
padding: 20px;
}
.hero-stats {
grid-template-columns: 1fr;
}
.hero-top {
flex-direction: column;
gap: 12px;
}
.hero-edit-btn {
width: 100%;
}
.stat-grid {
grid-template-columns: 1fr;
}
.stat-tile--span2 {
grid-column: auto;
}
.section-card-header {
padding: 14px 16px 0;
}
.section-divider {
margin: 12px 16px 0;
}
.section-card-body {
padding: 16px;
}
} }
</style> </style>
+453 -166
View File
@@ -9,100 +9,125 @@
<StateLoading v-else-if="loading" :rows="6" /> <StateLoading v-else-if="loading" :rows="6" />
<template v-else> <template v-else>
<el-card class="detail-card unified-shell" shadow="never"> <!-- ==================== 合同信息 ==================== -->
<template #header> <div class="section-card">
<div class="card-header actions-header"> <div class="section-card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.contractInfoTitle }}</span> <div class="section-card-header-left">
<div class="actions"> <span class="accent-dot accent-dot--blue" />
<el-button v-if="canWrite" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn copy-btn"> <span class="section-card-title">{{ TEXT.modules.feeContracts.contractInfoTitle }}</span>
</div>
<div class="section-card-header-right">
<div v-if="detail.contract_no" class="contract-badge">{{ detail.contract_no }}</div>
<el-button v-if="canWrite" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-edit-btn">
<el-icon class="el-icon--left"><Edit /></el-icon> <el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
</div> </div>
</div> </div>
</template> <div class="section-divider" />
<div class="info-grid"> <div class="section-card-body">
<div class="info-item info-item--highlight"> <div class="stat-grid">
<span class="info-label">{{ TEXT.modules.feeContracts.contractAmount }}</span> <div class="stat-tile stat-tile--highlight">
<span class="info-value amount-value">{{ formatAmountWan(detail.contract_amount) }}<span class="amount-unit"></span></span> <span class="stat-tile-label">{{ TEXT.modules.feeContracts.contractAmount }}</span>
<span class="stat-tile-value stat-tile-value--lg">{{ formatAmountWan(detail.contract_amount) }}<span class="stat-tile-unit"></span></span>
</div> </div>
<div class="info-item"> <div class="stat-tile">
<span class="info-label">{{ TEXT.common.fields.site }}</span> <span class="stat-tile-label">{{ TEXT.common.fields.site }}</span>
<span class="info-value">{{ centerName || TEXT.common.fallback }}</span> <span class="stat-tile-value">{{ centerName || TEXT.common.fallback }}</span>
</div> </div>
<div class="info-item"> <div class="stat-tile">
<span class="info-label">{{ TEXT.common.fields.contractNo }}</span> <span class="stat-tile-label">{{ TEXT.common.fields.signedDate }}</span>
<span class="info-value">{{ detail.contract_no || TEXT.common.fallback }}</span> <span class="stat-tile-value">{{ displayDate(detail.signed_date) }}</span>
</div> </div>
<div class="info-item"> <div class="stat-tile">
<span class="info-label">{{ TEXT.common.fields.signedDate }}</span> <span class="stat-tile-label">{{ TEXT.modules.feeContracts.contractCases }}</span>
<span class="info-value">{{ displayDate(detail.signed_date) }}</span> <span class="stat-tile-value">{{ detail.contract_cases ?? TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.modules.feeContracts.actualCases }}</span>
<span class="stat-tile-value">{{ detail.actual_cases ?? TEXT.common.fallback }}</span>
</div>
<div class="stat-tile stat-tile--span2">
<span class="stat-tile-label">{{ TEXT.common.fields.remark }}</span>
<span class="stat-tile-value stat-tile-value--muted">{{ detail.remark || TEXT.common.fallback }}</span>
</div> </div>
<div class="info-item">
<span class="info-label">{{ TEXT.modules.feeContracts.contractCases }}</span>
<span class="info-value">{{ detail.contract_cases ?? TEXT.common.fallback }}</span>
</div> </div>
<div class="info-item">
<span class="info-label">{{ TEXT.modules.feeContracts.actualCases }}</span>
<span class="info-value">{{ detail.actual_cases ?? TEXT.common.fallback }}</span>
</div>
<div class="info-item info-item--full">
<span class="info-label">{{ TEXT.common.fields.remark }}</span>
<span class="info-value">{{ detail.remark || TEXT.common.fallback }}</span>
</div> </div>
</div> </div>
</el-card>
<el-card class="section-card unified-shell" shadow="never"> <!-- ==================== 分期付款 Timeline ==================== -->
<template #header> <div class="section-card">
<div class="card-header"> <div class="section-card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span> <div class="section-card-header-left">
<span class="accent-dot accent-dot--amber" />
<span class="section-card-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
</div>
<el-tag v-if="detail.payments?.length" size="small" class="payment-count-tag" round>{{ detail.payments.length }} {{ TEXT.modules.feeContracts.paymentSeqUnit }}</el-tag>
</div>
<div class="section-divider" />
<div class="section-card-body section-card-body--no-padding">
<div v-if="!detail.payments?.length" class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span class="empty-text">{{ TEXT.modules.feeContracts.paymentEmpty }}</span>
</div>
<div v-else class="timeline">
<div v-for="(payment, index) in detail.payments" :key="index" class="tl-row">
<div class="tl-track">
<div :class="['tl-node', payment.is_paid ? 'tl-node--done' : 'tl-node--todo']">
<svg v-if="payment.is_paid" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
<span v-else class="tl-node-num">{{ (index as number) + 1 }}</span>
</div>
<div v-if="showPaymentLine(index)" class="tl-stem" />
</div>
<div :class="['tl-panel', payment.is_paid ? 'tl-panel--paid' : '']">
<div class="tl-panel-top">
<div class="tl-panel-seq">
{{ TEXT.modules.feeContracts.paymentSeq }}{{ (index as number) + 1 }}{{ TEXT.modules.feeContracts.paymentSeqUnit }}
</div>
<div class="tl-panel-amount">
{{ formatAmountWan(payment.amount) }}<span class="amount-unit-sm"></span>
</div>
</div>
<div class="tl-panel-body">
<div class="tl-meta-row">
<div class="tl-meta">
<span class="tl-meta-label">{{ TEXT.modules.feeContracts.paidFlag }}</span>
<span :class="['tl-pill', payment.is_paid ? 'tl-pill--paid' : 'tl-pill--none']">
{{ payment.is_paid ? TEXT.modules.feeContracts.isPaid : TEXT.common.fallback }}
</span>
<span v-if="payment.is_paid" class="tl-meta-date">{{ displayDate(payment.paid_date) }}</span>
</div>
<div class="tl-meta">
<span class="tl-meta-label">{{ TEXT.modules.feeContracts.verifiedFlag }}</span>
<span :class="['tl-pill', payment.is_verified ? 'tl-pill--verified' : 'tl-pill--none']">
{{ payment.is_verified ? TEXT.modules.feeContracts.isVerified : TEXT.common.fallback }}
</span>
<span v-if="payment.is_verified" class="tl-meta-date">{{ displayDate(payment.verified_date) }}</span>
</div>
</div>
<div v-if="payment.remark" class="tl-note">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
{{ payment.remark }}
</div>
</div>
</div>
</div>
</div> </div>
</template>
<el-table :data="detail.payments" style="width: 100%" :header-cell-style="{ background: '#f8f9fb' }" table-layout="fixed">
<el-table-column :label="TEXT.modules.feeContracts.paymentSeqColumn" min-width="180">
<template #default="scope">
<span class="seq-tag">{{ TEXT.modules.feeContracts.paymentSeq }}{{ scope.row.seq }}{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" align="left" header-align="left" min-width="180">
<template #default="scope">
<span class="amount-text">{{ formatAmountWan(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.paidFlag" min-width="180">
<template #default="scope">
<div v-if="scope.row.is_paid" class="status-cell">
<el-tag type="success" size="small">{{ TEXT.modules.feeContracts.isPaid }}</el-tag>
<span class="status-date">{{ displayDate(scope.row.paid_date) }}</span>
</div> </div>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifiedFlag" min-width="180">
<template #default="scope">
<div v-if="scope.row.is_verified" class="status-cell">
<el-tag type="info" size="small">{{ TEXT.modules.feeContracts.isVerified }}</el-tag>
<span class="status-date">{{ displayDate(scope.row.verified_date) }}</span>
</div> </div>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.remark" min-width="180" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<template #empty>
<div class="table-empty">{{ TEXT.modules.feeContracts.paymentEmpty }}</div>
</template>
</el-table>
</el-card>
<el-card class="section-card unified-shell" shadow="never"> <!-- ==================== 附件区 ==================== -->
<template #header> <div class="section-card">
<div class="card-header"> <div class="section-card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span> <div class="section-card-header-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#22c55e;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="section-card-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
</div> </div>
</template> </div>
<div class="section-divider" />
<div class="section-card-body">
<AttachmentList <AttachmentList
v-if="contractId" v-if="contractId"
:study-id="studyId" :study-id="studyId"
@@ -113,7 +138,8 @@
:hide-uploader="true" :hide-uploader="true"
:refresh-key="attachmentRefreshKey" :refresh-key="attachmentRefreshKey"
/> />
</el-card> </div>
</div>
</template> </template>
<ContractFeeEditorDrawer <ContractFeeEditorDrawer
@@ -227,6 +253,10 @@ const load = async () => {
} }
}; };
const showPaymentLine = (i: any): boolean => {
return Array.isArray(detail.payments) && Number(i) < detail.payments.length - 1;
};
const formatAmountWan = (value: any) => { const formatAmountWan = (value: any) => {
const numberValue = Number(value); const numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback; if (Number.isNaN(numberValue)) return TEXT.common.fallback;
@@ -257,157 +287,414 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
/* ==================== Base ==================== */
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
padding: 4px 0;
}
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
}
.detail-card, .section-card {
border-radius: 14px;
border: 1px solid var(--unified-shell-divider, #eaf0f8);
overflow: hidden;
}
.section-card :deep(.el-card__body) {
padding: 0; padding: 0;
background: transparent;
} }
.card-header { /* ==================== Section Cards ==================== */
padding: 4px 0; .section-card {
background: #ffffff;
border-radius: 6px;
overflow: hidden;
box-shadow: none;
} }
.actions-header { .section-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 18px 24px 0;
} }
.header-title { .section-card-header-left {
font-size: 15px; display: flex;
font-weight: 700; align-items: center;
color: var(--unified-title-color, #0f2345); gap: 10px;
border-left: 3px solid var(--el-color-primary);
padding-left: 10px;
} }
.info-grid { .section-card-header-right {
display: flex;
align-items: center;
gap: 10px;
}
.section-card-title {
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
letter-spacing: -0.01em;
}
.section-divider {
display: none;
}
.section-card-body {
padding: 20px 24px 24px;
}
.section-card-body--no-padding {
padding: 0;
}
/* ==================== Accent Dots ==================== */
.accent-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.accent-dot--blue { background: #3b82f6; }
.accent-dot--amber { background: #f59e0b; }
.accent-dot--green { background: #22c55e; }
/* ==================== Contract Badge ==================== */
.contract-badge {
padding: 2px 10px;
font-size: 11px;
font-weight: 500;
color: #525252;
background: #f5f5f5;
border-radius: 4px;
letter-spacing: 0.01em;
}
.header-edit-btn {
height: 32px;
border-radius: 8px;
padding: 0 14px;
font-size: 13px;
font-weight: 500;
}
/* ==================== Stat Tiles ==================== */
.stat-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 0; gap: 8px;
padding: 20px 24px;
} }
.info-item { .stat-tile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
padding: 14px 16px; padding: 12px 14px;
border-radius: 8px; background: #fafbfc;
transition: background 0.15s ease; border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.15s ease;
} }
.info-item:hover { .stat-tile:hover {
background: #f8fafc; background: #f5f6f8;
border-color: #e5e5e5;
} }
.info-item--highlight { .stat-tile--highlight {
background: linear-gradient(135deg, #f0f6ff 0%, #eef3fb 100%); background: linear-gradient(135deg, #f8faff 0%, #f0f4ff 100%);
border-radius: 10px; border-color: #e0e8f8;
} }
.info-item--highlight:hover { .stat-tile--highlight:hover {
background: linear-gradient(135deg, #e8f1ff 0%, #e6edfa 100%); background: linear-gradient(135deg, #f0f6ff 0%, #e8f0ff 100%);
} }
.info-item--full { .stat-tile--span2 {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.info-label { .stat-tile-label {
font-size: 12px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--unified-muted-color, #6f84a8); color: #8a8a8a;
letter-spacing: 0.02em; letter-spacing: 0.03em;
text-transform: uppercase;
} }
.info-value { .stat-tile-value {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 600;
color: var(--ctms-text-main); color: #0a0a0a;
word-break: break-all; word-break: break-all;
}
.amount-value {
font-size: 22px;
font-weight: 700;
color: var(--ctms-primary);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.amount-unit { .stat-tile-value--lg {
font-size: 13px; font-size: 22px;
font-weight: 700;
color: #2563eb;
}
.stat-tile-value--muted {
font-weight: 500; font-weight: 500;
color: var(--unified-muted-color, #6f84a8); color: #525252;
}
.stat-tile-unit {
font-size: 12px;
font-weight: 500;
color: #a3a3a3;
margin-left: 2px; margin-left: 2px;
} }
.seq-tag { /* ==================== Payment Count Tag ==================== */
color: var(--ctms-text-secondary); .payment-count-tag {
font-size: 13px; font-weight: 500;
border: 1px solid #e5e5e5;
color: #525252;
background: #fafafa;
} }
.status-cell { /* ==================== Timeline ==================== */
.timeline {
padding: 20px 24px 4px;
}
.tl-row {
display: flex;
gap: 16px;
}
.tl-track {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: center;
gap: 4px; width: 28px;
flex-shrink: 0;
} }
.status-date { .tl-node {
font-size: 12px;
color: var(--ctms-text-secondary);
font-family: var(--el-font-family);
}
.text-muted {
color: var(--ctms-text-placeholder, #94a3b8);
font-size: 13px;
}
.table-empty {
min-height: 120px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #8a97ab; width: 28px;
font-size: 13px; height: 28px;
font-weight: 500; border-radius: 50%;
z-index: 1;
flex-shrink: 0;
transition: all 0.2s ease;
} }
.tl-node--done {
background: #3b82f6;
color: #ffffff;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
}
.tl-node--todo {
background: #ffffff;
color: #a3a3a3;
border: 1.5px solid #d4d4d4;
}
.tl-node-num {
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.tl-stem {
width: 1.5px;
flex: 1;
min-height: 16px;
background: #e5e5e5;
}
.tl-panel {
flex: 1;
padding: 14px 18px;
margin-bottom: 16px;
background: #ffffff;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.tl-panel:hover {
border-color: #d4d4d4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.tl-panel--paid {
border-left: 2.5px solid #22c55e;
}
.tl-panel-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f5f5f5;
}
.tl-panel-seq {
font-size: 13px;
font-weight: 600;
color: #0a0a0a;
}
.tl-panel-amount {
font-size: 18px;
font-weight: 700;
color: #2563eb;
font-variant-numeric: tabular-nums;
}
.tl-panel-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.tl-meta-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.tl-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tl-meta-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tl-pill {
display: inline-flex;
align-items: center;
padding: 1px 9px;
font-size: 11px;
font-weight: 600;
border-radius: 20px;
line-height: 1.6;
}
.tl-pill--paid {
background: #dcfce7;
color: #16a34a;
}
.tl-pill--verified {
background: #dbeafe;
color: #2563eb;
}
.tl-pill--none {
background: #f5f5f5;
color: #a3a3a3;
}
.tl-meta-date {
font-size: 11px;
color: #8a8a8a;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.tl-note {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
color: #737373;
background: #fafafa;
padding: 8px 12px;
border-radius: 6px;
line-height: 1.5;
}
.tl-note svg {
flex-shrink: 0;
margin-top: 2px;
color: #a3a3a3;
}
.amount-unit-sm {
font-size: 11px;
font-weight: 500;
color: #a3a3a3;
margin-left: 1px;
}
/* ==================== Empty State ==================== */
.empty-state {
min-height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
}
.empty-text {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
}
/* ==================== Responsive ==================== */
@media (max-width: 960px) { @media (max-width: 960px) {
.info-grid { .stat-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
padding: 16px;
} }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.info-grid { .page {
padding: 0;
}
.stat-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 12px; }
.stat-tile--span2 {
grid-column: auto;
}
.section-card-header {
padding: 14px 16px 0;
}
.section-divider {
margin: 12px 16px 0;
}
.section-card-body {
padding: 16px;
}
.timeline {
padding: 16px 16px 0;
}
.tl-panel {
padding: 12px 14px;
margin-bottom: 12px;
}
.tl-meta-row {
flex-direction: column;
gap: 8px;
} }
} }
</style> </style>
@@ -1,49 +1,142 @@
<template> <template>
<div class="page"> <div class="page">
<div v-if="study.currentStudy" class="unified-shell"> <StateEmpty v-if="!study.currentStudy" :description="TEXT.common.empty.selectProject" />
<section v-loading="loading" class="unified-section">
<div class="unified-section-header"> <template v-else>
<div> <StateError v-if="errorMessage" :description="errorMessage">
<div class="ctms-section-title">{{ TEXT.modules.materialEquipment.detailTitle }}</div> <template #action>
<div class="detail-subtitle">{{ TEXT.modules.materialEquipment.detailSubtitle }}</div> <el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<!-- ==================== Hero ==================== -->
<div class="hero-banner">
<div class="hero-bg-pattern" />
<div class="hero-content">
<div class="hero-top">
<div class="hero-title-group">
<h2 class="hero-title">{{ TEXT.modules.materialEquipment.detailTitle }}</h2>
<span :class="['hero-status-badge', detail.need_calibration ? 'hero-status-badge--warn' : 'hero-status-badge--info']">
{{ detail.need_calibration ? "需要校准" : "无需校准" }}
</span>
</div> </div>
<div class="actions"> <el-button v-if="canUpdateEquipment" type="primary" @click="goEdit" class="hero-edit-btn">
<el-button v-if="canUpdateEquipment" type="primary" @click="goEdit" class="header-action-btn">
<el-icon class="el-icon--left"><Edit /></el-icon> <el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
</div> </div>
<div class="hero-stats">
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--name">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">设备名称</span>
<span class="stat-value">{{ detail.name || TEXT.common.fallback }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--spec">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">规格型号</span>
<span class="stat-value">{{ detail.spec_model || TEXT.common.fallback }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--brand">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">品牌</span>
<span class="stat-value">{{ detail.brand || TEXT.common.fallback }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--origin">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">产地</span>
<span class="stat-value">{{ detail.origin || TEXT.common.fallback }}</span>
</div>
</div>
</div>
</div>
</div> </div>
<div class="section-title-row"> <!-- ==================== 基本信息 ==================== -->
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span> <div class="section-card">
<el-tag :type="detail.need_calibration ? 'warning' : 'info'" effect="plain" round> <div class="section-card-header">
{{ detail.need_calibration ? "需要校准" : "无需校准" }} <div class="section-card-header-left">
</el-tag> <span class="accent-dot accent-dot--blue" />
<span class="section-card-title">{{ TEXT.common.labels.basicInfo }}</span>
</div>
</div>
<div class="section-divider" />
<div class="section-card-body">
<div class="stat-grid">
<div class="stat-tile">
<span class="stat-tile-label">设备名称</span>
<span class="stat-tile-value">{{ detail.name || TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">规格型号</span>
<span class="stat-tile-value stat-tile-value--mono">{{ detail.spec_model || TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">单位</span>
<span class="stat-tile-value">{{ detail.unit || TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">品牌</span>
<span class="stat-tile-value">{{ detail.brand || TEXT.common.fallback }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">产地</span>
<span class="stat-tile-value">{{ detail.origin || TEXT.common.fallback }}</span>
</div>
</div>
</div>
</div> </div>
<el-descriptions :column="2" border>
<el-descriptions-item label="设备名称">{{ displayValue(detail.name) }}</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ displayValue(detail.spec_model) }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ displayValue(detail.unit) }}</el-descriptions-item>
<el-descriptions-item label="品牌">{{ displayValue(detail.brand) }}</el-descriptions-item>
<el-descriptions-item label="产地">{{ displayValue(detail.origin) }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section"> <!-- ==================== 校准设置 ==================== -->
<div class="ctms-section-title section-title-inline">校准设置</div> <div class="section-card">
<el-descriptions :column="2" border> <div class="section-card-header">
<el-descriptions-item label="是否需要校准"> <div class="section-card-header-left">
{{ detail.need_calibration ? TEXT.common.labels.yes : TEXT.common.labels.no }} <span class="accent-dot accent-dot--amber" />
</el-descriptions-item> <span class="section-card-title">校准设置</span>
<el-descriptions-item label="校准周期(天)"> </div>
{{ detail.need_calibration ? displayValue(detail.calibration_cycle_days) : TEXT.common.fallback }} </div>
</el-descriptions-item> <div class="section-divider" />
</el-descriptions> <div class="section-card-body">
</section> <div class="stat-grid">
<div class="stat-tile">
<span class="stat-tile-label">是否需要校准</span>
<span class="stat-tile-value">{{ detail.need_calibration ? "是" : "否" }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">校准周期</span>
<span class="stat-tile-value">{{ detail.need_calibration ? (detail.calibration_cycle_days ?? TEXT.common.fallback) + ' 天' : TEXT.common.fallback }}</span>
</div>
</div>
</div>
</div>
<section class="unified-section"> <!-- ==================== 附件 ==================== -->
<div class="ctms-section-title section-title-inline">{{ TEXT.common.labels.attachments }}</div> <div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#22c55e;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
</div>
</div>
<div class="section-divider" />
<div class="section-card-body">
<AttachmentList <AttachmentList
v-if="equipmentId && studyId" v-if="equipmentId && studyId"
:study-id="studyId" :study-id="studyId"
@@ -52,7 +145,11 @@
:hide-uploader="true" :hide-uploader="true"
:refresh-key="attachmentRefreshKey" :refresh-key="attachmentRefreshKey"
/> />
</section> </div>
</div>
</template>
</template>
<MaterialEquipmentEditorDrawer <MaterialEquipmentEditorDrawer
v-model="editorVisible" v-model="editorVisible"
:equipment-id="equipmentId || ''" :equipment-id="equipmentId || ''"
@@ -60,8 +157,6 @@
@saved="handleEditorSaved" @saved="handleEditorSaved"
/> />
</div> </div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -71,8 +166,10 @@ import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue"; import { Edit } from "@element-plus/icons-vue";
import { getMaterialEquipment } from "../../api/materialEquipments"; import { getMaterialEquipment } from "../../api/materialEquipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import MaterialEquipmentEditorDrawer from "./MaterialEquipmentEditorDrawer.vue"; import MaterialEquipmentEditorDrawer from "./MaterialEquipmentEditorDrawer.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
@@ -83,6 +180,7 @@ const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false); const editorVisible = ref(false);
const attachmentRefreshKey = ref(0); const attachmentRefreshKey = ref(0);
const equipmentId = computed(() => route.params.equipmentId as string | undefined); const equipmentId = computed(() => route.params.equipmentId as string | undefined);
@@ -100,11 +198,6 @@ const detail = reactive({
calibration_cycle_days: null as number | null, calibration_cycle_days: null as number | null,
}); });
const displayValue = (value?: string | number | null) => {
if (value === null || value === undefined || value === "") return TEXT.common.fallback;
return value;
};
const goEdit = () => { const goEdit = () => {
if (!canUpdateEquipment.value) { if (!canUpdateEquipment.value) {
ElMessage.warning("权限不足"); ElMessage.warning("权限不足");
@@ -121,6 +214,7 @@ const handleEditorSaved = () => {
const load = async () => { const load = async () => {
if (!studyId.value || !equipmentId.value) return; if (!studyId.value || !equipmentId.value) return;
loading.value = true; loading.value = true;
errorMessage.value = "";
try { try {
const { data } = await getMaterialEquipment(studyId.value, equipmentId.value); const { data } = await getMaterialEquipment(studyId.value, equipmentId.value);
Object.assign(detail, { Object.assign(detail, {
@@ -137,7 +231,7 @@ const load = async () => {
pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle, pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle,
}); });
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed); errorMessage.value = e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -154,37 +248,304 @@ onMounted(load);
</script> </script>
<style scoped> <style scoped>
/* ==================== Base ==================== */
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 16px;
padding: 0;
background: transparent;
} }
.detail-subtitle { /* ==================== Hero Banner ==================== */
margin-top: 4px; .hero-banner {
color: var(--ctms-text-secondary); position: relative;
font-size: 13px; background: #fff7ee;
border: 1px solid rgba(180, 110, 50, 0.10);
border-radius: 8px;
padding: 28px 32px 24px;
overflow: hidden;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.7) inset,
0 1px 2px rgba(180, 110, 50, 0.04);
} }
.section-title-row { .hero-bg-pattern { display: none; }
.hero-content {
position: relative;
z-index: 1;
}
.hero-top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 12px; margin-bottom: 20px;
} }
.actions { .hero-title-group {
display: flex; display: flex;
align-items: center;
gap: 12px; gap: 12px;
} }
.header-action-btn { .hero-title {
height: 36px; margin: 0;
border-radius: 8px; font-size: 19px;
padding: 0 16px; font-weight: 600;
color: #3d2410;
letter-spacing: 0.005em;
} }
.section-title-inline { .hero-status-badge {
margin-bottom: 12px; display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 11px;
font-weight: 500;
border-radius: 20px;
letter-spacing: 0.02em;
backdrop-filter: blur(8px);
}
.hero-status-badge--warn {
background: rgba(255, 255, 255, 0.55);
color: rgba(80, 45, 20, 0.78);
border: 1px solid rgba(180, 110, 50, 0.18);
}
.hero-status-badge--info {
background: rgba(255, 255, 255, 0.55);
color: rgba(80, 45, 20, 0.55);
border: 1px solid rgba(180, 110, 50, 0.12);
}
.hero-edit-btn {
height: 34px;
border-radius: 6px;
padding: 0 18px;
font-weight: 500;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(180, 110, 50, 0.18);
color: #3d2410;
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
transition: all 0.2s ease;
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 1px 2px rgba(180, 110, 50, 0.06);
}
.hero-edit-btn:hover {
background: #ffffff;
border-color: rgba(180, 110, 50, 0.30);
color: #3d2410;
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 4px 12px rgba(212, 130, 60, 0.14);
}
.hero-edit-btn:active {
transform: scale(0.97);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.hero-stat {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.62) 100%);
border: 1px solid rgba(180, 110, 50, 0.10);
border-radius: 6px;
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
transition: all 0.25s ease;
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 1px 2px rgba(180, 110, 50, 0.04);
}
.hero-stat:hover {
background: linear-gradient(180deg, #ffffff 0%, rgba(255, 255, 255, 0.92) 100%);
border-color: rgba(180, 110, 50, 0.18);
transform: translateY(-1px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 6px 16px rgba(212, 130, 60, 0.10);
}
.stat-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
flex-shrink: 0;
border: 1px solid rgba(180, 110, 50, 0.06);
}
.stat-icon-wrap--name { background: rgba(212, 145, 80, 0.14); color: #b06b30; }
.stat-icon-wrap--spec { background: rgba(168, 158, 80, 0.16); color: #8a7028; }
.stat-icon-wrap--brand { background: rgba(196, 116, 92, 0.14); color: #a85838; }
.stat-icon-wrap--origin { background: rgba(220, 160, 90, 0.18); color: #b87532; }
.stat-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.stat-label {
font-size: 11px;
font-weight: 500;
color: rgba(80, 45, 20, 0.50);
letter-spacing: 0.03em;
text-transform: uppercase;
}
.stat-value {
font-size: 14px;
font-weight: 600;
color: #3d2410;
word-break: break-all;
}
/* ==================== Section Cards ==================== */
.section-card {
background: #ffffff;
border-radius: 6px;
overflow: hidden;
box-shadow: none;
}
.section-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px 0;
}
.section-card-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.section-card-title {
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
letter-spacing: -0.01em;
}
.section-divider {
display: none;
}
.section-card-body {
padding: 20px 24px 24px;
}
/* ==================== Accent Dots ==================== */
.accent-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.accent-dot--blue { background: #3b82f6; }
.accent-dot--amber { background: #f59e0b; }
.accent-dot--green { background: #22c55e; }
/* ==================== Stat Grid ==================== */
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.stat-tile {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
background: #fafbfc;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.15s ease;
}
.stat-tile:hover {
background: #f5f6f8;
border-color: #e5e5e5;
}
.stat-tile-label {
font-size: 11px;
font-weight: 500;
color: #8a8a8a;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.stat-tile-value {
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
word-break: break-all;
}
.stat-tile-value--mono {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
}
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.hero-stats {
grid-template-columns: repeat(2, 1fr);
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.hero-banner {
padding: 20px;
}
.hero-stats {
grid-template-columns: 1fr;
}
.hero-top {
flex-direction: column;
gap: 12px;
}
.hero-edit-btn {
width: 100%;
}
.stat-grid {
grid-template-columns: 1fr;
}
.section-card-header {
padding: 14px 16px 0;
}
.section-divider {
margin: 12px 16px 0;
}
.section-card-body {
padding: 16px;
}
} }
</style> </style>