抽屉化物资与启动授权流程

1、将药物发货新建和编辑从独立页面迁移到抽屉,详情页支持权限控制和停用中心只读状态。

2、补充药物发货状态字段校验,统一 PENDING、IN_TRANSIT、SIGNED、EXCEPTION 状态及演示数据。

3、为物资设备增加详情页和编辑抽屉,复用通用附件上传并按项目权限控制操作入口。

4、将启动会和培训授权编辑迁移到抽屉,详情页按中心过滤培训记录并移除旧独立编辑路由。
This commit is contained in:
Cheng Zhou
2026-06-04 11:08:43 +08:00
parent 720c98765f
commit 6a2e43f829
31 changed files with 3773 additions and 1009 deletions
+688 -166
View File
@@ -1,196 +1,571 @@
<template>
<div class="page page--flush">
<div v-if="study.currentStudy" class="unified-shell">
<section class="unified-section section--flush">
<div class="filter-row">
<el-form :inline="true" :model="filters">
<el-form-item :label="TEXT.common.fields.site">
<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>
</el-form-item>
<el-form-item :label="TEXT.common.fields.direction">
<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>
</el-form-item>
<el-form-item :label="TEXT.common.fields.status">
<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>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
<el-button v-if="canCreate" type="primary" @click="openCreate">{{ TEXT.modules.drugShipments.newTitle }}</el-button>
</div>
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item">
<el-select v-model="filters.center_id" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<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">
<el-select v-model="filters.direction" clearable :placeholder="TEXT.common.fields.direction" class="filter-select">
<template #prefix>
<el-icon><Menu /></el-icon>
</template>
<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">
<el-select v-model="filters.status" clearable :placeholder="TEXT.common.fields.status" class="filter-select">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<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.RETURNED" value="RETURNED" />
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
</el-select>
</div>
<div class="filter-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" @click="goNew">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.drugShipments.newTitle }}
</el-button>
</el-form>
</div>
<div class="unified-section table-section section--flush-x section--flush-top section--flush-bottom">
<el-table
:data="sortedItems"
v-loading="loading"
:data="sortedItems"
class="ctms-table shipment-table"
style="width: 100%"
class="shipment-table"
table-layout="fixed"
:row-class-name="shipmentRowClass"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" width="150" show-overflow-tooltip>
<template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }">{{ row.site_name || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="120">
<template #default="scope">
<el-tag :class="['type-tag', scope.row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="{ row }">
<el-tag :class="['type-tag', row.direction === 'SEND' ? 'type-send' : 'type-return']" effect="light">
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="165">
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="{ row }">{{ displayDate(row.ship_date) }}</template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="145">
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="{ row }">{{ displayDate(row.receive_date) }}</template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="100" align="right">
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="{ row }">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" width="120" show-overflow-tooltip>
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="{ row }">{{ row.batch_no || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)" effect="plain" round size="small" class="status-tag">
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" effect="plain" round size="small" class="status-tag">
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="110" align="center">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" width="130">
<template #default="{ row }">
<div class="shipment-actions">
<el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
v-if="canDelete"
link
type="danger"
: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">{{ TEXT.modules.drugShipments.empty }}</div>
</template>
</el-table>
</div>
</section>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<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-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.common.labels.basicInfo }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<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-col>
<el-col :span="12">
<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>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<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>
</el-col>
</el-row>
<div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
<!-- 运输记录分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-record"></span>
{{ TEXT.modules.drugShipments.recordLabel }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<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-col>
<el-col :span="12">
<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>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<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-col>
<el-col :span="12">
<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>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<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-col>
<el-col :span="12">
<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>
</el-col>
</el-row>
</div>
<!-- 备注分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-remark"></span>
{{ 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-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
{{ 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">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">{{ 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 } from "element-plus";
import { Location, Menu, CircleCheck, Plus } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
import {
createDrugShipment,
deleteDrugShipment,
listDrugShipments,
updateDrugShipment,
} from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
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";
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 router = useRouter();
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false);
const items = ref<any[]>([]);
const saving = ref(false);
const items = ref<ShipmentRow[]>([]);
const sites = ref<any[]>([]);
const siteActiveMap = ref<Record<string, boolean>>({});
const drawerVisible = ref(false);
const editingId = ref("");
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const filters = reactive({
center_id: study.currentSite?.id || "",
direction: "",
status: "",
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 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) return;
if (!studyId) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
siteActiveMap.value = sites.value.reduce((acc: Record<string, boolean>, site: any) => {
acc[site.id] = !!site.is_active;
return acc;
}, {});
} catch {
sites.value = [];
siteActiveMap.value = {};
}
};
const load = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!studyId) {
items.value = [];
return;
}
loading.value = true;
try {
const { data } = await listDrugShipments(studyId, {});
items.value = Array.isArray(data) ? data : data?.items || [];
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) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
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 goNew = () => router.push("/drug/shipments/new");
const goDetail = (id: string) => router.push(`/drug/shipments/${id}`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
const handleSearch = () => {
load();
};
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: any }) =>
const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const filteredItems = computed(() =>
items.value.filter((item) => {
if (filters.center_id && item?.center_id !== filters.center_id) return false;
if (filters.direction && item?.direction !== filters.direction) return false;
if (filters.status && item?.status !== filters.status) return false;
return true;
})
);
const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const onRowClick = (row: ShipmentRow) => {
if (!row?.id) return;
router.push(`/drug/shipments/${row.id}`);
};
const statusType = (status: string) => {
switch (status) {
@@ -200,8 +575,6 @@ const statusType = (status: string) => {
return "primary";
case "SIGNED":
return "success";
case "RETURNED":
return "warning";
case "EXCEPTION":
return "danger";
default:
@@ -209,40 +582,62 @@ const statusType = (status: string) => {
}
};
const remove = async (row: any) => {
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).catch(() => null);
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);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
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(s => s.id === val);
if (matched) study.setCurrentSite(matched);
} else {
study.setCurrentSite(null);
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.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();
}
);
onMounted(async () => {
await loadSites();
load();
await load();
});
</script>
@@ -252,46 +647,176 @@ onMounted(async () => {
flex-direction: column;
}
.filter-form {
.filter-row {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
}
.filter-item {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-actions {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-actions :deep(.el-form-item__content) {
align-items: center;
}
.filter-actions :deep(.el-button) {
height: 32px;
line-height: 32px;
padding: 0 12px;
border-radius: 10px;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
}
.filter-select {
width: 140px;
width: 150px;
}
.filter-spacer {
flex: 1;
/* ========== 抽屉头部 ========== */
:deep(.shipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.shipment-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px;
}
.editor-header {
display: flex;
flex-direction: column;
}
.editor-title {
font-size: 20px;
font-weight: 700;
color: var(--ctms-text-main);
line-height: 1.2;
}
/* ========== 表单整体 ========== */
.shipment-form {
padding: 0 4px 0 0;
}
/* ========== 分组卡片 ========== */
.form-group {
border: 1px solid #e8eef6;
border-radius: 10px;
padding: 16px 18px 8px;
background: #fbfcfe;
transition: border-color 0.2s ease;
}
.form-group:hover {
border-color: #d0dced;
}
.form-group + .form-group {
margin-top: 14px;
}
.form-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
color: #1a3560;
display: flex;
align-items: center;
gap: 8px;
}
.group-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* 基本信息 - 蓝色 */
.group-dot-basic {
background: #3b82f6;
}
/* 运输记录 - 琥珀色 */
.group-dot-record {
background: #f0ad2c;
}
/* 备注 - 绿色 */
.group-dot-remark {
background: #22c55e;
}
/* 附件 - 紫色 */
.group-dot-attachment {
background: #8b5cf6;
}
.inactive-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #fff7d6;
border: 1px solid #fde68a;
border-radius: 8px;
font-size: 13px;
color: #92400e;
margin-bottom: 8px;
}
.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;
}
/* ========== 表单元素细节 ========== */
.full-width {
width: 100%;
}
/* ========== 底部按钮 ========== */
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ========== 表单元素微调 ========== */
.shipment-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.shipment-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
padding-bottom: 4px;
}
.shipment-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
/* ========== 表格居中 ========== */
.shipment-table :deep(th.el-table__cell .cell),
.shipment-table :deep(td.el-table__cell .cell) {
text-align: center;
}
.shipment-actions {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
white-space: nowrap;
}
.shipment-actions :deep(.el-button) {
height: auto;
padding: 0;
}
.shipment-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.type-tag {
font-weight: 600;
border: none;
@@ -311,9 +836,6 @@ onMounted(async () => {
font-weight: 500;
}
.text-muted {
color: var(--ctms-text-placeholder);
}
.table-empty {
min-height: 220px;
display: flex;