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 app.schemas.user import UserDisplay
class DocumentVersionStatus(str, enum.Enum):
DRAFT = "DRAFT"
@@ -39,6 +41,7 @@ class DocumentVersionRead(BaseModel):
mime_type: Optional[str] = None
change_summary: Optional[str] = None
created_by: Optional[uuid.UUID] = None
created_by_user: Optional[UserDisplay] = None
submitted_at: Optional[datetime] = None
approved_at: Optional[datetime] = None
withdrawn_at: Optional[datetime] = None
+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):
study = await study_crud.get(db, trial_id)
if not study:
@@ -242,6 +249,8 @@ async def get_document_detail(
owner_obj = await user_crud.get_by_id(db, doc.owner_id)
if owner_obj:
owner = UserDisplay.model_validate(owner_obj)
creator_ids = {v.created_by for v in versions if v.created_by}
version_creators = await user_crud.get_users_by_ids(db, creator_ids)
return DocumentDetail(
id=doc.id,
trial_id=doc.trial_id,
@@ -257,7 +266,7 @@ async def get_document_detail(
created_at=doc.created_at,
updated_at=doc.updated_at,
owner=owner,
version_timeline=[DocumentVersionRead.model_validate(v) for v in versions],
version_timeline=[_version_read(v, version_creators) for v in versions],
distribution_stats=distribution_stats,
)
@@ -55,19 +55,28 @@
</el-table-column>
</el-table>
</template>
<div v-else class="upload-grid">
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-card">
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
<el-upload
class="attachment-card-upload"
:show-file-list="false"
multiple
:disabled="!canUploadGroup(group) || isUploading(group.key)"
:auto-upload="false"
:on-change="(file: any) => queuePendingUpload(group.key, file)"
>
<div class="upload-trigger" :class="{ 'is-disabled': !canUploadGroup(group) }">
<el-icon class="upload-icon"><Document /></el-icon>
<span class="upload-text">点击上传文件</span>
<div
class="attachment-upload-card"
: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>
</el-upload>
<div v-if="pendingMap[group.key]?.length" class="pending-list">
@@ -112,7 +121,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
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 { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth";
@@ -126,12 +135,14 @@ import { TEXT } from "../../locales";
type AttachmentEntityGroup = {
entityType: string;
label: string;
uploadText?: string;
};
type UploadGroup = {
key: string;
label: string;
entityType: string;
uploadText?: string;
};
type PendingUploadStatus = "pending" | "uploading" | "failed";
@@ -153,6 +164,9 @@ const props = defineProps<{
mode?: "table" | "upload";
maxSizeMb?: number;
refreshKey?: number;
hideUploadCardLabels?: boolean;
centerUploadCardContent?: boolean;
uploadCardColumns?: 2 | 3;
}>();
const attachments = ref<any[]>([]);
@@ -178,11 +192,13 @@ const uploadGroups = computed<UploadGroup[]>(() => {
key: group.entityType,
label: group.label,
entityType: group.entityType,
uploadText: group.uploadText,
}));
}
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 role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
@@ -494,20 +510,64 @@ defineExpose({
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 {
min-height: 72px;
padding: 14px 16px;
border: 1px dashed #d0dced;
border-radius: 8px;
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);
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 {
margin-bottom: 10px;
color: #4a6283;
@@ -520,25 +580,22 @@ defineExpose({
align-items: center;
gap: 8px;
min-height: 34px;
cursor: pointer;
}
.upload-trigger.is-disabled {
cursor: not-allowed;
opacity: 0.62;
}
.upload-icon {
font-size: 18px;
font-size: 22px;
line-height: 1;
color: #314a6b;
}
.upload-text {
color: var(--ctms-text-secondary);
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);
}
+6
View File
@@ -45,6 +45,12 @@ export interface DocumentVersion {
mime_type?: string | null;
change_summary?: 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;
approved_at?: string | null;
withdrawn_at?: string | null;
@@ -50,6 +50,38 @@ describe("DocumentDetail permissions", () => {
expect(source).toContain('can("project.members.list")');
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", () => {
@@ -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=\"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('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 prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip>
<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>
</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 }">
<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 }}
</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 }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
@@ -96,6 +101,7 @@
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="editorDirtyGuard.beforeClose"
:show-close="false"
destroy-on-close
class="document-editor-drawer"
@@ -167,9 +173,10 @@ import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import type { DocumentSummary } from "../../types/documents";
import type { Site } from "../../types/api";
import { displayEnum, displayText } from "../../utils/display";
import { displayDateTime, displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales";
const route = useRoute();
@@ -239,6 +246,7 @@ const editorForm = reactive({
site_id: "",
doc_type: "",
});
const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const editorRules: FormRules = {
doc_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
@@ -318,6 +326,7 @@ const openCreate = () => {
}
editingDocumentId.value = "";
resetEditorForm();
editorDirtyGuard.syncBaseline();
editorVisible.value = true;
};
@@ -339,6 +348,7 @@ const openEdit = (row: DocumentSummary) => {
doc_type: row.doc_type || "",
});
editorFormRef.value?.clearValidate();
editorDirtyGuard.syncBaseline();
editorVisible.value = true;
};
@@ -416,9 +426,10 @@ const confirmDelete = async (row: DocumentSummary) => {
}
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
const splitDateTime = (value?: string | null) => {
const displayValue = displayDateTime(value);
const [date, time] = displayValue.split(" ");
return { date, time: time || "" };
};
onMounted(() => {
@@ -461,10 +472,6 @@ watch(
width: 132px;
}
.ctms-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.filter-spacer {
flex: 1;
}
@@ -524,6 +531,93 @@ watch(
gap: 10px;
}
/* ==================== Table Styling (DrugShipments style) ==================== */
.document-table-section {
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 8px rgba(0, 0, 0, 0.02);
}
.ctms-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
.ctms-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.ctms-table :deep(th.el-table__cell) {
background: #f4f5f7;
color: #2a2a2a;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 14px 14px;
border-bottom: 2px solid #e0e0e0 !important;
}
.ctms-table :deep(td.el-table__cell) {
padding: 12px 14px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
}
.ctms-table :deep(.el-table__body tr) {
cursor: pointer;
transition: background 0.1s ease;
}
.ctms-table :deep(.font-semibold) {
font-weight: 600;
color: #0a0a0a;
}
.ctms-table :deep(.text-secondary) {
color: #8a8a8a;
}
.ctms-table :deep(.datetime-stack) {
display: inline-flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.ctms-table :deep(.datetime-stack__time) {
color: #9a9a9a;
font-size: 11px;
}
.ctms-table :deep(.el-tag) {
font-weight: 600;
border: none;
}
.ctms-table :deep(.cell-actions) {
display: flex;
gap: 8px;
white-space: nowrap;
}
.ctms-table :deep(.cell-actions .el-button) {
font-size: 13px;
padding: 0;
height: auto;
}
.ctms-table :deep(.cell-actions .el-button + .el-button) {
margin-left: 0;
}
</style>
<style>
+443 -75
View File
@@ -1,50 +1,126 @@
<template>
<div class="page">
<div class="unified-shell">
<section v-loading="loading" class="unified-section">
<div class="unified-section-header">
<div>
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
<div class="detail-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</div>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<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.drugShipments.detailTitle }}</h2>
<span :class="['hero-status-badge', `hero-status-badge--${statusType(detail.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
</span>
</div>
<div class="actions">
<el-button v-if="canUpdateShipment" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn">
<el-button v-if="canUpdateShipment" type="primary" :disabled="isReadOnly" @click="goEdit" class="hero-edit-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</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 class="section-title-row">
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
<el-tag :type="statusType(detail.status)" effect="plain" round>
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
</el-tag>
<!-- ==================== 运输记录 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<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>
<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>
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.trackingNo">{{ detail.tracking_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.carrier">{{ detail.carrier || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.shipDate">{{ displayDate(detail.ship_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.receiveDate">{{ displayDate(detail.receive_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.quantity">{{ detail.quantity ?? TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.batchNo">{{ detail.batch_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section attachment-section">
<!-- ==================== 附件 ==================== -->
<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
v-if="shipmentId && studyId"
:study-id="studyId"
@@ -53,8 +129,10 @@
:readonly="isReadOnly"
:hide-uploader="true"
/>
</section>
</div>
</div>
</template>
<DrugShipmentEditorDrawer
v-model="editorVisible"
:shipment-id="shipmentId || ''"
@@ -76,6 +154,8 @@ import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import DrugShipmentEditorDrawer from "./DrugShipmentEditorDrawer.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
@@ -84,6 +164,7 @@ const route = useRoute();
const auth = useAuthStore();
const study = useStudyStore();
const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false);
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const studyId = computed(() => study.currentStudy?.id || "");
@@ -129,6 +210,7 @@ const loadSites = async () => {
const load = async () => {
if (!studyId.value || !shipmentId.value) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(detail, {
@@ -150,7 +232,7 @@ const load = async () => {
pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle,
});
} 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 {
loading.value = false;
}
@@ -174,16 +256,11 @@ const handleEditorSaved = () => {
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";
}
};
@@ -202,56 +279,347 @@ onMounted(async () => {
</script>
<style scoped>
/* ==================== Base ==================== */
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.detail-subtitle {
margin-top: 4px;
color: var(--ctms-text-secondary);
font-size: 13px;
}
.attachment-section {
gap: 16px;
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;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.hero-title-group {
display: flex;
align-items: center;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
.hero-title {
margin: 0;
font-size: 19px;
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;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding: 18px 24px 0;
}
.section-title-inline {
margin-bottom: 12px;
.section-card-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.type-tag {
.section-card-title {
font-size: 14px;
font-weight: 600;
border: none;
color: #0a0a0a;
letter-spacing: -0.01em;
}
.type-send {
background-color: #fff7ed !important;
color: #ea580c !important;
.badge {
padding: 2px 10px;
font-size: 11px;
font-weight: 500;
color: #525252;
background: #f5f5f5;
border-radius: 4px;
letter-spacing: 0.01em;
}
.type-return {
background-color: #fefce8 !important;
color: #ca8a04 !important;
.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--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>
+453 -166
View File
@@ -9,100 +9,125 @@
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-card class="detail-card unified-shell" shadow="never">
<template #header>
<div class="card-header actions-header">
<span class="header-title">{{ TEXT.modules.feeContracts.contractInfoTitle }}</span>
<div class="actions">
<el-button v-if="canWrite" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn copy-btn">
<!-- ==================== 合同信息 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<span class="accent-dot accent-dot--blue" />
<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>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
</div>
</template>
<div class="info-grid">
<div class="info-item info-item--highlight">
<span class="info-label">{{ TEXT.modules.feeContracts.contractAmount }}</span>
<span class="info-value amount-value">{{ formatAmountWan(detail.contract_amount) }}<span class="amount-unit"></span></span>
<div class="section-divider" />
<div class="section-card-body">
<div class="stat-grid">
<div class="stat-tile stat-tile--highlight">
<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 class="info-item">
<span class="info-label">{{ TEXT.common.fields.site }}</span>
<span class="info-value">{{ centerName || TEXT.common.fallback }}</span>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.site }}</span>
<span class="stat-tile-value">{{ centerName || TEXT.common.fallback }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ TEXT.common.fields.contractNo }}</span>
<span class="info-value">{{ detail.contract_no || TEXT.common.fallback }}</span>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.common.fields.signedDate }}</span>
<span class="stat-tile-value">{{ displayDate(detail.signed_date) }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ TEXT.common.fields.signedDate }}</span>
<span class="info-value">{{ displayDate(detail.signed_date) }}</span>
<div class="stat-tile">
<span class="stat-tile-label">{{ TEXT.modules.feeContracts.contractCases }}</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 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 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>
</el-card>
<el-card class="section-card unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
<!-- ==================== 分期付款 Timeline ==================== -->
<div class="section-card">
<div class="section-card-header">
<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>
</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>
<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>
<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="card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
<!-- ==================== 附件区 ==================== -->
<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.modules.feeContracts.attachmentTitle }}</span>
</div>
</template>
</div>
<div class="section-divider" />
<div class="section-card-body">
<AttachmentList
v-if="contractId"
:study-id="studyId"
@@ -113,7 +138,8 @@
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</el-card>
</div>
</div>
</template>
<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 numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback;
@@ -257,157 +287,414 @@ onMounted(async () => {
</script>
<style scoped>
/* ==================== Base ==================== */
.page {
display: flex;
flex-direction: column;
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;
background: transparent;
}
.card-header {
padding: 4px 0;
/* ==================== Section Cards ==================== */
.section-card {
background: #ffffff;
border-radius: 6px;
overflow: hidden;
box-shadow: none;
}
.actions-header {
.section-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px 0;
}
.header-title {
font-size: 15px;
font-weight: 700;
color: var(--unified-title-color, #0f2345);
border-left: 3px solid var(--el-color-primary);
padding-left: 10px;
.section-card-header-left {
display: flex;
align-items: center;
gap: 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;
grid-template-columns: repeat(4, 1fr);
gap: 0;
padding: 20px 24px;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.info-item {
.stat-tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border-radius: 8px;
transition: background 0.15s ease;
gap: 4px;
padding: 12px 14px;
background: #fafbfc;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.15s ease;
}
.info-item:hover {
background: #f8fafc;
.stat-tile:hover {
background: #f5f6f8;
border-color: #e5e5e5;
}
.info-item--highlight {
background: linear-gradient(135deg, #f0f6ff 0%, #eef3fb 100%);
border-radius: 10px;
.stat-tile--highlight {
background: linear-gradient(135deg, #f8faff 0%, #f0f4ff 100%);
border-color: #e0e8f8;
}
.info-item--highlight:hover {
background: linear-gradient(135deg, #e8f1ff 0%, #e6edfa 100%);
.stat-tile--highlight:hover {
background: linear-gradient(135deg, #f0f6ff 0%, #e8f0ff 100%);
}
.info-item--full {
.stat-tile--span2 {
grid-column: 1 / -1;
}
.info-label {
font-size: 12px;
.stat-tile-label {
font-size: 11px;
font-weight: 500;
color: var(--unified-muted-color, #6f84a8);
letter-spacing: 0.02em;
color: #8a8a8a;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.info-value {
.stat-tile-value {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
font-weight: 600;
color: #0a0a0a;
word-break: break-all;
}
.amount-value {
font-size: 22px;
font-weight: 700;
color: var(--ctms-primary);
font-variant-numeric: tabular-nums;
}
.amount-unit {
font-size: 13px;
.stat-tile-value--lg {
font-size: 22px;
font-weight: 700;
color: #2563eb;
}
.stat-tile-value--muted {
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;
}
.seq-tag {
color: var(--ctms-text-secondary);
font-size: 13px;
/* ==================== Payment Count Tag ==================== */
.payment-count-tag {
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;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-items: center;
width: 28px;
flex-shrink: 0;
}
.status-date {
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;
.tl-node {
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 13px;
font-weight: 500;
width: 28px;
height: 28px;
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) {
.info-grid {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
padding: 16px;
}
}
@media (max-width: 640px) {
.info-grid {
.page {
padding: 0;
}
.stat-grid {
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>
@@ -1,49 +1,142 @@
<template>
<div class="page">
<div v-if="study.currentStudy" class="unified-shell">
<section v-loading="loading" class="unified-section">
<div class="unified-section-header">
<div>
<div class="ctms-section-title">{{ TEXT.modules.materialEquipment.detailTitle }}</div>
<div class="detail-subtitle">{{ TEXT.modules.materialEquipment.detailSubtitle }}</div>
<StateEmpty v-if="!study.currentStudy" :description="TEXT.common.empty.selectProject" />
<template v-else>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<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 class="actions">
<el-button v-if="canUpdateEquipment" type="primary" @click="goEdit" class="header-action-btn">
<el-button v-if="canUpdateEquipment" type="primary" @click="goEdit" class="hero-edit-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</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 class="section-title-row">
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
<el-tag :type="detail.need_calibration ? 'warning' : 'info'" effect="plain" round>
{{ detail.need_calibration ? "需要校准" : "无需校准" }}
</el-tag>
<!-- ==================== 基本信息 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<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>
<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>
<el-descriptions :column="2" border>
<el-descriptions-item label="是否需要校准">
{{ detail.need_calibration ? TEXT.common.labels.yes : TEXT.common.labels.no }}
</el-descriptions-item>
<el-descriptions-item label="校准周期(天)">
{{ detail.need_calibration ? displayValue(detail.calibration_cycle_days) : TEXT.common.fallback }}
</el-descriptions-item>
</el-descriptions>
</section>
<!-- ==================== 校准设置 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<span class="accent-dot accent-dot--amber" />
<span class="section-card-title">校准设置</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.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
v-if="equipmentId && studyId"
:study-id="studyId"
@@ -52,7 +145,11 @@
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</section>
</div>
</div>
</template>
</template>
<MaterialEquipmentEditorDrawer
v-model="editorVisible"
:equipment-id="equipmentId || ''"
@@ -60,8 +157,6 @@
@saved="handleEditorSaved"
/>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div>
</template>
<script setup lang="ts">
@@ -71,8 +166,10 @@ import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import { getMaterialEquipment } from "../../api/materialEquipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.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 { useStudyStore } from "../../store/study";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
@@ -83,6 +180,7 @@ const route = useRoute();
const auth = useAuthStore();
const study = useStudyStore();
const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false);
const attachmentRefreshKey = ref(0);
const equipmentId = computed(() => route.params.equipmentId as string | undefined);
@@ -100,11 +198,6 @@ const detail = reactive({
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 = () => {
if (!canUpdateEquipment.value) {
ElMessage.warning("权限不足");
@@ -121,6 +214,7 @@ const handleEditorSaved = () => {
const load = async () => {
if (!studyId.value || !equipmentId.value) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await getMaterialEquipment(studyId.value, equipmentId.value);
Object.assign(detail, {
@@ -137,7 +231,7 @@ const load = async () => {
pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle,
});
} 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 {
loading.value = false;
}
@@ -154,37 +248,304 @@ onMounted(load);
</script>
<style scoped>
/* ==================== Base ==================== */
.page {
display: flex;
flex-direction: column;
gap: 0;
gap: 16px;
padding: 0;
background: transparent;
}
.detail-subtitle {
margin-top: 4px;
color: var(--ctms-text-secondary);
font-size: 13px;
/* ==================== 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);
}
.section-title-row {
.hero-bg-pattern { display: none; }
.hero-content {
position: relative;
z-index: 1;
}
.hero-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
align-items: flex-start;
margin-bottom: 20px;
}
.actions {
.hero-title-group {
display: flex;
align-items: center;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
.hero-title {
margin: 0;
font-size: 19px;
font-weight: 600;
color: #3d2410;
letter-spacing: 0.005em;
}
.section-title-inline {
margin-bottom: 12px;
.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--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>