release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
+469 -69
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
@@ -47,85 +47,178 @@
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="sortedItems"
class="shipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="shipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="{ row }">
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="{ row }">
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<div class="shipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="shipmentTablePaneRef"
class="shipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedShipment"
>
<el-table
v-loading="loading"
:data="sortedItems"
class="shipment-table"
style="width: 100%"
table-layout="fixed"
highlight-current-row
:row-class-name="shipmentRowClass"
@row-click="handleShipmentRowClick"
@row-dblclick="openShipmentDetail"
@row-contextmenu="openShipmentContextMenu"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="{ row }">
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="{ row }">
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
v-if="canDelete"
link
type="danger"
size="small"
:disabled="isInactiveSite(row.center_id)"
@click.stop="remove(row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<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>{{ TEXT.modules.drugShipments.empty }}</span>
</div>
</template>
</el-table>
</div>
<aside v-if="isDesktop" class="shipment-preview-pane">
<template v-if="selectedShipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前发运</div>
<div class="preview-title">{{ selectedShipment.site_name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedShipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.common.fields.direction }}</dt>
<dd>{{ displayEnum(TEXT.enums.shipmentDirection, selectedShipment.direction) }}</dd>
<dt>{{ TEXT.common.fields.status }}</dt>
<dd>{{ displayEnum(TEXT.enums.shipmentStatus, selectedShipment.status) }}</dd>
<dt>{{ TEXT.common.fields.shipDate }}</dt>
<dd>{{ displayDate(selectedShipment.ship_date) }}</dd>
<dt>{{ TEXT.common.fields.receiveDate }}</dt>
<dd>{{ displayDate(selectedShipment.receive_date) }}</dd>
<dt>{{ TEXT.common.fields.quantity }}</dt>
<dd>{{ typeof selectedShipment.quantity === "number" ? selectedShipment.quantity : TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.batchNo }}</dt>
<dd>{{ selectedShipment.batch_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.carrier }}</dt>
<dd>{{ selectedShipment.carrier || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.trackingNo }}</dt>
<dd>{{ selectedShipment.tracking_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.remark }}</dt>
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button
v-if="canUpdate"
size="small"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDelete"
link
type="danger"
size="small"
:disabled="isInactiveSite(row.center_id)"
@click.stop="remove(row)"
type="danger"
plain
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<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>{{ TEXT.modules.drugShipments.empty }}</span>
<div v-else class="preview-empty">
<span>选择一行查看发运摘要</span>
</div>
</template>
</el-table>
</aside>
</div>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<div
v-if="isDesktop && shipmentContextMenu.visible && selectedShipment"
class="shipment-context-menu"
:style="{ left: `${shipmentContextMenu.x}px`, top: `${shipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedShipment">打开详情</button>
<button
v-if="canUpdate"
type="button"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</button>
<button
v-if="canDelete"
type="button"
class="danger"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</button>
</div>
<!-- ==================== 编辑抽屉 ==================== -->
<el-drawer
v-if="drawerVisible"
@@ -290,6 +383,7 @@ import { displayDate, displayEnum } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { isTauriRuntime } from "../../runtime";
type ShipmentDirection = "SEND" | "RETURN";
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
@@ -322,6 +416,10 @@ const drawerVisible = ref(false);
const editingId = ref("");
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const shipmentTablePaneRef = ref<HTMLElement | null>(null);
const isDesktop = isTauriRuntime();
const selectedShipmentId = ref("");
const shipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const filters = reactive({
center_id: study.currentSite?.id || "",
direction: "" as "" | ShipmentDirection,
@@ -363,6 +461,9 @@ const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !can
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const selectedShipment = computed(() =>
sortedItems.value.find((item) => item.id === selectedShipmentId.value) || null
);
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
@@ -564,12 +665,69 @@ const handleSearch = () => {
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const onRowClick = (row: ShipmentRow) => {
[
row?.id ? "clickable-row" : "",
isInactiveSite(row?.center_id) ? "row-inactive" : "",
isDesktop && row?.id === selectedShipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const selectShipment = (row: ShipmentRow) => {
if (!row?.id) return;
selectedShipmentId.value = row.id;
shipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleShipmentRowClick = (row: ShipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectShipment(row);
return;
}
router.push(`/drug/shipments/${row.id}`);
};
const openShipmentDetail = (row?: ShipmentRow | null) => {
const target = row?.id ? row : selectedShipment.value;
if (target?.id) router.push(`/drug/shipments/${target.id}`);
};
const openSelectedShipment = () => {
closeShipmentContextMenu();
openShipmentDetail(selectedShipment.value);
};
const openSelectedShipmentEditor = () => {
const target = selectedShipment.value;
closeShipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedShipment = () => {
const target = selectedShipment.value;
closeShipmentContextMenu();
if (target) {
void remove(target);
}
};
const closeShipmentContextMenu = () => {
if (!shipmentContextMenu.value.visible) return;
shipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const openShipmentContextMenu = (row: ShipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectShipment(row);
shipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 184)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 132)),
};
};
const statusType = (status: string) => {
switch (status) {
case "PENDING": return "info";
@@ -633,6 +791,22 @@ watch(
}
);
watch(
() => sortedItems.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
const rows = sortedItems.value;
if (!rows.length) {
selectedShipmentId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedShipmentId.value)) {
selectedShipmentId.value = rows[0].id;
}
},
{ immediate: true }
);
onMounted(async () => {
await loadSites();
await load();
@@ -648,12 +822,26 @@ onMounted(async () => {
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.shipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.shipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Page Header ==================== */
.create-btn {
height: 34px;
@@ -733,6 +921,167 @@ onMounted(async () => {
0 2px 8px rgba(0, 0, 0, 0.02);
}
/* 桌面端:table-card 填满、无圆角 */
.shipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.shipment-workbench {
min-width: 0;
min-height: 0;
}
.shipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 316px;
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.shipment-table-pane {
min-width: 0;
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.shipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.shipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.shipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #f8fafc;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-kicker {
margin-bottom: 5px;
color: #6b7e95;
font-size: 11px;
font-weight: 800;
}
.preview-title {
display: -webkit-box;
overflow: hidden;
color: #142033;
font-size: 17px;
font-weight: 800;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.preview-list {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
}
.preview-list dt {
color: #7b8da3;
font-size: 12px;
font-weight: 700;
}
.preview-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #223349;
font-size: 13px;
font-weight: 650;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: #7b8da3;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.shipment-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 172px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.shipment-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.shipment-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.shipment-context-menu button.danger {
color: #b42318;
}
.shipment-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.shipment-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -771,6 +1120,10 @@ onMounted(async () => {
transition: background 0.1s ease;
}
.shipment-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.cell-nowrap {
white-space: nowrap;
}
@@ -1035,6 +1388,15 @@ onMounted(async () => {
gap: 12px;
align-items: stretch;
}
.shipment-workbench.is-desktop {
grid-template-columns: minmax(0, 1fr);
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 0;
}
.shipment-preview-pane {
display: none;
}
}
@media (max-width: 640px) {
@@ -1042,6 +1404,44 @@ onMounted(async () => {
width: 100%;
}
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .table-card) {
background: #172033;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-preview-pane) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-title),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dd) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-kicker),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dt),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-table .el-table__body tr.row-selected > td) {
background: #243247 !important;
}
</style>
<style>