Files
ctms/frontend/src/views/ia/DrugShipments.vue
T
Cheng Zhou d5279b124f
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
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

1458 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.site }}</span>
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.direction }}</span>
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">{{ TEXT.common.fields.status }}</span>
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.placeholders.select" class="filter-select">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</div>
<div class="filter-actions">
<el-button type="primary" size="small" @click="handleSearch" class="filter-btn">{{ TEXT.common.actions.search }}</el-button>
<el-button size="small" @click="resetFilters" class="filter-btn">{{ TEXT.common.actions.reset }}</el-button>
</div>
</div>
<div class="toolbar-right">
<div v-if="items.length" class="filter-summary">
{{ items.length }} 条记录
</div>
<el-button v-if="canCreate" type="primary" @click="openCreate" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.drugShipments.newTitle }}
</el-button>
</div>
</div>
<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"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
<div v-else class="preview-empty">
<span>选择一行查看发运摘要</span>
</div>
</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"
v-model="drawerVisible"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="shipment-editor-drawer"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ editingId ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="shipment-form">
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--blue" />
{{ TEXT.common.labels.basicInfo }}
</div>
<div class="field-grid field-grid--2">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
:disabled="!site.is_active"
/>
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item>
</div>
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" class="full-width" :disabled="isFormReadOnly">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</el-form-item>
<div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--amber" />
{{ TEXT.modules.drugShipments.recordLabel }}
</div>
<div class="field-grid field-grid--2">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date" :required="requiresShipmentDetails(form.status)">
<el-date-picker
v-model="form.ship_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
:disabled="isFormReadOnly"
/>
</el-form-item>
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date" :required="requiresReceiveDate(form.status)">
<el-date-picker
v-model="form.receive_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
:disabled="isFormReadOnly"
/>
</el-form-item>
</div>
<div class="field-grid field-grid--2">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity" :required="requiresShipmentDetails(form.status)">
<el-input-number v-model="form.quantity" :min="0" :controls="false" class="full-width" :disabled="isFormReadOnly" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isFormReadOnly" />
</el-form-item>
</div>
<div class="field-grid field-grid--2">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isFormReadOnly" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no" :required="requiresShipmentDetails(form.status)">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isFormReadOnly" />
</el-form-item>
</div>
</div>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--green" />
{{ TEXT.common.fields.remark }}
</div>
<el-form-item prop="remark" :required="requiresRemark(form.status)">
<el-input
v-model="form.remark"
type="textarea"
:rows="4"
:placeholder="TEXT.common.placeholders.input + TEXT.common.fields.remark"
:disabled="isFormReadOnly"
/>
</el-form-item>
</div>
<div class="form-section">
<div class="form-section-title">
<span class="dot dot--purple" />
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="study.currentStudy?.id || ''"
entity-type="drug_shipment"
:entity-id="editingId"
:mode="'upload'"
:readonly="isFormReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false" class="footer-btn">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm" class="footer-btn footer-btn--primary">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import {
createDrugShipment,
deleteDrugShipment,
listDrugShipments,
updateDrugShipment,
} from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
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";
interface ShipmentRow {
id: string;
center_id: string;
site_name: string;
direction: ShipmentDirection;
ship_date: string;
receive_date: string;
quantity: number | null;
batch_no: string;
carrier: string;
tracking_no: string;
status: ShipmentStatus;
remark: string;
}
type FormModel = Omit<ShipmentRow, "id" | "site_name">;
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const items = ref<ShipmentRow[]>([]);
const sites = ref<any[]>([]);
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,
status: "" as "" | ShipmentStatus,
});
const defaultForm: FormModel = {
center_id: "",
direction: "SEND",
ship_date: "",
receive_date: "",
quantity: null,
batch_no: "",
carrier: "",
tracking_no: "",
status: "PENDING",
remark: "",
};
const form = reactive<FormModel>({ ...defaultForm });
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const isAdmin = computed(() => isSystemAdmin(auth.user));
const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:create"]));
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:delete"]));
const siteActiveMap = computed(() =>
sites.value.reduce((acc: Record<string, boolean>, site: any) => {
if (site?.id) acc[site.id] = !!site.is_active;
return acc;
}, {})
);
const selectedSiteInactive = computed(() => !!editingId.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value) || selectedSiteInactive.value);
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";
const validateShipmentDetail = (_rule: unknown, value: string | number | null, callback: (error?: Error) => void) => {
if (requiresShipmentDetails(form.status) && (value === null || value === undefined || value === "")) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateShipmentTextDetail = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresShipmentDetails(form.status) && !value?.trim()) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateReceiveDate = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresReceiveDate(form.status) && !value) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const validateRemark = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (requiresRemark(form.status) && !value?.trim()) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
};
const rules: FormRules<FormModel> = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
ship_date: [{ validator: validateShipmentDetail, trigger: "change" }],
receive_date: [{ validator: validateReceiveDate, trigger: "change" }],
quantity: [{ validator: validateShipmentDetail, trigger: "change" }],
batch_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
carrier: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
tracking_no: [{ validator: validateShipmentTextDetail, trigger: "blur" }],
status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
remark: [{ validator: validateRemark, trigger: "blur" }],
};
const normalizeShipment = (item: any): ShipmentRow => ({
id: item.id,
center_id: item.center_id || "",
site_name: item.site_name || "",
direction: item.direction || "SEND",
ship_date: item.ship_date || "",
receive_date: item.receive_date || "",
quantity: item.quantity ?? null,
batch_no: item.batch_no || "",
carrier: item.carrier || "",
tracking_no: item.tracking_no || "",
status: item.status || "PENDING",
remark: item.remark || "",
});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) {
items.value = [];
return;
}
loading.value = true;
try {
const { data } = await listDrugShipments(studyId, {
center_id: filters.center_id || undefined,
direction: filters.direction || undefined,
status: filters.status || undefined,
limit: 500,
});
const list = Array.isArray(data) ? data : data?.items || [];
items.value = list.map(normalizeShipment);
} catch (e: any) {
items.value = [];
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const resetForm = () => {
Object.assign(form, defaultForm);
formRef.value?.clearValidate();
};
const openCreate = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = "";
resetForm();
if (study.currentSite?.id && siteActiveMap.value[study.currentSite.id] !== false) {
form.center_id = study.currentSite.id;
}
drawerDirtyGuard.syncBaseline();
drawerVisible.value = true;
};
const openEdit = (row: ShipmentRow) => {
if (!canUpdate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = row.id;
Object.assign(form, {
center_id: row.center_id,
direction: row.direction,
ship_date: row.ship_date,
receive_date: row.receive_date,
quantity: row.quantity,
batch_no: row.batch_no,
carrier: row.carrier,
tracking_no: row.tracking_no,
status: row.status,
remark: row.remark,
});
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = true;
};
const saveForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const payload = {
center_id: form.center_id,
direction: form.direction,
ship_date: form.ship_date || null,
receive_date: form.receive_date || null,
quantity: form.quantity,
batch_no: form.batch_no.trim() || null,
carrier: form.carrier.trim() || null,
tracking_no: form.tracking_no.trim() || null,
status: form.status,
remark: form.remark.trim() || null,
};
saving.value = true;
try {
let shipmentId = editingId.value;
if (editingId.value) {
await updateDrugShipment(studyId, editingId.value, payload);
} else {
const { data } = (await createDrugShipment(studyId, payload)) as any;
shipmentId = data?.id || "";
if (shipmentId) editingId.value = shipmentId;
}
await attachmentPanelRef.value?.uploadPending(shipmentId);
await load();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const resetFilters = () => {
filters.center_id = "";
filters.direction = "";
filters.status = "";
study.setCurrentSite(null);
load();
};
const handleSearch = () => {
load();
};
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { 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";
case "IN_TRANSIT": return "primary";
case "SIGNED": return "success";
case "EXCEPTION": return "danger";
default: return "info";
}
};
const remove = async (row: ShipmentRow) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDelete.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.modules.drugShipments.confirmDelete, TEXT.common.labels.tips, { type: "warning" }).catch(() => null);
if (!ok) return;
try {
await deleteDrugShipment(studyId, row.id);
await load();
ElMessage.success(TEXT.common.messages.deleteSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
watch(
() => filters.center_id,
(val: string) => {
if (val) {
const matched = sites.value.find((site) => site.id === val);
if (matched) study.setCurrentSite(matched);
} else {
study.setCurrentSite(null);
}
}
);
watch(
() => study.currentSite,
(newSite: any) => {
filters.center_id = newSite?.id || "";
}
);
watch(
() => study.currentStudy?.id,
async () => {
filters.center_id = study.currentSite?.id || "";
filters.direction = "";
filters.status = "";
drawerVisible.value = false;
await loadSites();
await load();
}
);
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();
});
</script>
<style scoped>
/* ==================== Base ==================== */
.page {
display: flex;
flex-direction: column;
padding: 4px 0;
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;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
}
/* ==================== Table Toolbar ==================== */
.table-card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
gap: 12px;
}
.toolbar-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.filter-select {
width: 140px;
}
.filter-actions {
display: flex;
gap: 8px;
align-items: flex-end;
padding-bottom: 1px;
}
.filter-btn {
border-radius: 6px;
}
.filter-summary {
font-size: 12px;
font-weight: 500;
color: #a3a3a3;
white-space: nowrap;
}
/* ==================== Table Card ==================== */
.table-card {
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);
}
/* 桌面端: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;
}
.shipment-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.shipment-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;
}
.shipment-table :deep(th.el-table__cell .cell) {
display: flex;
align-items: center;
gap: 4px;
}
.shipment-table :deep(td.el-table__cell) {
padding: 12px 14px;
color: #0a0a0a;
font-size: 14px;
font-weight: 500;
}
.shipment-table :deep(.el-table__body tr) {
cursor: pointer;
transition: background 0.1s ease;
}
.shipment-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.cell-nowrap {
white-space: nowrap;
}
.cell-primary {
font-weight: 600;
color: #0a0a0a;
}
.cell-mono {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
color: #0a0a0a;
}
.cell-muted {
color: #737373;
font-size: 14px;
}
.cell-actions {
display: flex;
gap: 8px;
white-space: nowrap;
}
.cell-actions :deep(.el-button) {
height: auto;
padding: 0;
font-size: 13px;
}
.cell-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
/* ==================== Direction Tags ==================== */
.dir-tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 12px;
font-weight: 700;
border-radius: 4px;
line-height: 1.6;
}
.dir-tag--send {
background: #fff7ed;
color: #ea580c;
}
.dir-tag--return {
background: #f0fdf4;
color: #16a34a;
}
/* ==================== Status Pills ==================== */
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 12px;
font-weight: 700;
border-radius: 20px;
line-height: 1.6;
}
.status-pill--info {
background: #f5f5f5;
color: #737373;
}
.status-pill--primary {
background: #eff4ff;
color: #2563eb;
}
.status-pill--success {
background: #dcfce7;
color: #16a34a;
}
.status-pill--danger {
background: #fef2f2;
color: #dc2626;
}
/* ==================== Empty State ==================== */
.table-empty {
min-height: 200px;
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;
}
.table-empty span {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
}
/* ==================== Drawer Editor ==================== */
:deep(.shipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 20px 24px 8px;
}
:deep(.shipment-editor-drawer > .el-drawer__body) {
padding: 0 24px 4px;
}
.editor-header {
display: flex;
flex-direction: column;
}
.editor-title {
font-size: 18px;
font-weight: 700;
color: #0a0a0a;
line-height: 1.2;
letter-spacing: -0.01em;
}
.shipment-form {
padding: 0;
}
/* ==================== Form Sections ==================== */
.form-section {
padding-bottom: 6px;
}
.form-section + .form-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.form-section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
color: #0a0a0a;
margin-bottom: 16px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot--blue { background: #3b82f6; }
.dot--amber { background: #f59e0b; }
.dot--green { background: #22c55e; }
.dot--purple { background: #8b5cf6; }
.field-grid {
display: grid;
gap: 0 16px;
}
.field-grid--2 {
grid-template-columns: 1fr 1fr;
}
.full-width {
width: 100%;
}
.inactive-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-top: 4px;
}
.hint-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #f59e0b;
color: #ffffff;
font-size: 12px;
line-height: 1;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.footer-btn {
height: 36px;
border-radius: 8px;
padding: 0 20px;
font-size: 13px;
font-weight: 500;
}
.footer-btn--primary {
padding: 0 24px;
}
.shipment-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.shipment-form :deep(.el-form-item__label) {
font-size: 12px;
font-weight: 600;
color: #525252;
padding-bottom: 4px;
}
.shipment-form :deep(.el-form-item__error) {
font-size: 11px;
}
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.field-grid--2 {
grid-template-columns: 1fr;
}
.toolbar-filters {
flex-direction: column;
align-items: stretch;
}
.filter-select {
width: 100%;
}
.table-card-toolbar {
flex-direction: column;
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) {
.create-btn {
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>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .status-pill,
.row-inactive .dir-tag {
opacity: 0.6;
}
</style>