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

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
+21
View File
@@ -65,6 +65,27 @@ describe("admin project route permissions", () => {
expect(source).not.toContain("finance/contracts");
});
it("does not register standalone contract fee create or edit pages", () => {
const source = readRouter();
expect(source).not.toContain("FeeContractForm");
expect(source).not.toContain('name: "FeeContractNew"');
expect(source).not.toContain('name: "FeeContractEdit"');
expect(source).not.toContain('path: "fees/contracts/new"');
expect(source).not.toContain('path: "fees/contracts/:contractId/edit"');
});
it("does not register standalone startup auth edit pages", () => {
const source = readRouter();
expect(source).not.toContain('name: "StartupKickoffEdit"');
expect(source).not.toContain('name: "StartupTrainingEdit"');
expect(source).not.toContain('name: "StartupTrainingNew"');
expect(source).not.toContain('path: "startup/kickoff/:meetingId/edit"');
expect(source).not.toContain('path: "startup/training/new"');
expect(source).not.toContain('path: "startup/training/:recordId/edit"');
});
it("uses precautions routes instead of knowledge note routes", () => {
const source = readRouter();
+13 -51
View File
@@ -23,10 +23,11 @@ import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue";
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
import FeeContracts from "../views/fees/ContractFees.vue";
import FeeContractForm from "../views/fees/ContractFeeForm.vue";
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
import DrugShipments from "../views/ia/DrugShipments.vue";
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
import DocumentList from "../views/documents/DocumentList.vue";
import DocumentDetail from "../views/documents/DocumentDetail.vue";
@@ -41,15 +42,12 @@ import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
import Precautions from "../views/ia/Precautions.vue";
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
import ShipmentForm from "../views/drug/ShipmentForm.vue";
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
import EthicsForm from "../views/startup/EthicsForm.vue";
import EthicsDetail from "../views/startup/EthicsDetail.vue";
import KickoffForm from "../views/startup/KickoffForm.vue";
import KickoffDetail from "../views/startup/KickoffDetail.vue";
import TrainingForm from "../views/startup/TrainingForm.vue";
import TrainingDetail from "../views/startup/TrainingDetail.vue";
import PrecautionForm from "../views/knowledge/PrecautionForm.vue";
import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
@@ -109,42 +107,18 @@ const routes: RouteRecordRaw[] = [
component: FeeContracts,
meta: { title: TEXT.menu.feeContracts, requiresStudy: true },
},
{
path: "fees/contracts/new",
name: "FeeContractNew",
component: FeeContractForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.feeContracts, requiresStudy: true },
},
{
path: "fees/contracts/:contractId",
name: "FeeContractDetail",
component: FeeContractDetail,
meta: { title: TEXT.modules.feeContracts.detailTitle, requiresStudy: true },
},
{
path: "fees/contracts/:contractId/edit",
name: "FeeContractEdit",
component: FeeContractForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.feeContracts, requiresStudy: true },
},
{
path: "drug/shipments",
name: "DrugShipments",
component: DrugShipments,
meta: { title: TEXT.menu.drugShipments, requiresStudy: true },
},
{
path: "materials/equipment",
name: "MaterialEquipment",
component: MaterialEquipment,
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
},
{
path: "drug/shipments/new",
name: "DrugShipmentNew",
component: ShipmentForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.drugShipments, requiresStudy: true },
},
{
path: "drug/shipments/:shipmentId",
name: "DrugShipmentDetail",
@@ -152,10 +126,16 @@ const routes: RouteRecordRaw[] = [
meta: { title: TEXT.modules.drugShipments.detailTitle, requiresStudy: true },
},
{
path: "drug/shipments/:shipmentId/edit",
name: "DrugShipmentEdit",
component: ShipmentForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.drugShipments, requiresStudy: true },
path: "materials/equipment",
name: "MaterialEquipment",
component: MaterialEquipment,
meta: { title: TEXT.menu.materialEquipment, requiresStudy: true },
},
{
path: "materials/equipment/:equipmentId",
name: "MaterialEquipmentDetail",
component: MaterialEquipmentDetail,
meta: { title: TEXT.modules.materialEquipment.detailTitle, requiresStudy: true },
},
{
path: "file-versions",
@@ -235,30 +215,12 @@ const routes: RouteRecordRaw[] = [
component: KickoffDetail,
meta: { title: TEXT.modules.startupMeetingAuth.kickoffDetailTitle, requiresStudy: true },
},
{
path: "startup/kickoff/:meetingId/edit",
name: "StartupKickoffEdit",
component: KickoffForm,
meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.kickoffTab, requiresStudy: true },
},
{
path: "startup/training/new",
name: "StartupTrainingNew",
component: TrainingForm,
meta: { title: TEXT.common.actions.add + TEXT.common.fields.person, requiresStudy: true },
},
{
path: "startup/training/:recordId",
name: "StartupTrainingDetail",
component: TrainingDetail,
meta: { title: TEXT.modules.startupMeetingAuth.trainingDetailTitle, requiresStudy: true },
},
{
path: "startup/training/:recordId/edit",
name: "StartupTrainingEdit",
component: TrainingForm,
meta: { title: TEXT.common.actions.edit + TEXT.modules.startupMeetingAuth.trainingTab, requiresStudy: true },
},
{
path: "subjects",
name: "SubjectManagement",
@@ -447,7 +409,7 @@ const router = createRouter({
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
project_members: { read: "project_members:list", write: "project_members:update" },
project_members: { read: "project_members:read", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" },
};
+2 -2
View File
@@ -13,7 +13,7 @@ export const useStudyStore = defineStore("study", () => {
const currentStudy = ref<Study | null>(null);
const currentStudyRole = ref<string | null>(null);
const currentSite = ref<any | null>(null);
const viewContext = ref<{ siteName?: string } | null>(null);
const viewContext = ref<{ siteName?: string; pageTitle?: string } | null>(null);
const currentPermissions = ref<any | null>(null);
const normalizeUserKey = (value: string) => value.trim().toLowerCase();
@@ -229,7 +229,7 @@ export const useStudyStore = defineStore("study", () => {
}
};
const setViewContext = (ctx: { siteName?: string } | null) => {
const setViewContext = (ctx: { siteName?: string; pageTitle?: string } | null) => {
viewContext.value = ctx;
};
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipmentEditorDrawer.vue"), "utf8");
describe("DrugShipmentEditorDrawer attachments", () => {
it("uses a compact upload card instead of the full attachment table", () => {
const source = readSource();
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="drug_shipment"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.shipmentId)");
expect(source).toContain("uploadPending");
expect(source).not.toContain("uploadAttachment");
expect(source).not.toContain("pendingFiles");
expect(source).not.toContain("upload-card-label");
expect(source).not.toContain("<el-table");
});
});
describe("DrugShipmentEditorDrawer shipment validation", () => {
it("keeps shipment execution fields optional while status is pending", () => {
const source = readSource();
expect(source).toContain("requiresShipmentDetails");
expect(source).toContain('status !== "PENDING"');
expect(source).not.toContain('ship_date: [{ required: true');
expect(source).not.toContain('batch_no: [{ required: true');
expect(source).not.toContain('carrier: [{ required: true');
expect(source).not.toContain('tracking_no: [{ required: true');
});
it("does not expose the removed returned status", () => {
const source = readSource();
expect(source).not.toContain("RETURNED");
expect(source).not.toContain("已回收");
});
it("requires receive date when shipment is signed", () => {
const source = readSource();
expect(source).toContain("requiresReceiveDate");
expect(source).toContain('status === "SIGNED"');
expect(source).not.toContain('receive_date: [{ required: true');
});
it("keeps remark optional unless shipment is exceptional", () => {
const source = readSource();
expect(source).toContain("requiresRemark");
expect(source).toContain('status === "EXCEPTION"');
expect(source).not.toContain('remark: [{ required: true');
});
});
@@ -0,0 +1,462 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="shipment-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ TEXT.modules.drugShipments.editTitle }}</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="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
:mode="'upload'"
:readonly="isFormReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', 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>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { getDrugShipment, updateDrugShipment } from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
type ShipmentDirection = "SEND" | "RETURN";
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
type FormModel = {
center_id: string;
direction: ShipmentDirection;
ship_date: string;
receive_date: string;
quantity: number | null;
batch_no: string;
carrier: string;
tracking_no: string;
status: ShipmentStatus;
remark: string;
};
const props = defineProps<{ modelValue: boolean; shipmentId: string; readonly?: boolean }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "saved"): void;
}>();
const study = useStudyStore();
const formRef = ref<FormInstance>();
const saving = ref(false);
const sites = ref<any[]>([]);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive<FormModel>({
center_id: "",
direction: "SEND",
ship_date: "",
receive_date: "",
quantity: null,
batch_no: "",
carrier: "",
tracking_no: "",
status: "PENDING",
remark: "",
});
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
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(() => !!form.center_id && siteActiveMap.value[form.center_id] === false);
const isFormReadOnly = computed(() => !!props.readonly || selectedSiteInactive.value);
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 loadSites = async () => {
if (!studyId.value) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadShipment = async () => {
if (!studyId.value || !props.shipmentId) return;
try {
const { data } = await getDrugShipment(studyId.value, props.shipmentId);
Object.assign(form, {
center_id: data.center_id || "",
direction: data.direction || "SEND",
ship_date: data.ship_date || "",
receive_date: data.receive_date || "",
quantity: data.quantity ?? null,
batch_no: data.batch_no || "",
carrier: data.carrier || "",
tracking_no: data.tracking_no || "",
status: data.status || "PENDING",
remark: data.remark || "",
});
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const load = async () => {
await loadSites();
await loadShipment();
};
const saveForm = async () => {
if (!studyId.value || !props.shipmentId) return;
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
saving.value = true;
try {
await updateDrugShipment(studyId.value, props.shipmentId, {
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,
});
await attachmentPanelRef.value?.uploadPending(props.shipmentId);
drawerDirtyGuard.syncBaseline();
emit("saved");
emit("update:modelValue", 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;
}
};
watch(
() => [props.modelValue, props.shipmentId, studyId.value] as const,
([visible]) => {
if (visible) load();
},
{ immediate: true }
);
</script>
<style scoped>
: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;
}
</style>
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./ShipmentDetail.vue"), "utf8");
describe("ShipmentDetail edit drawer", () => {
it("shows an edit action and opens the shipment editor drawer on the detail page", () => {
const source = readSource();
expect(source).toContain("<DrugShipmentEditorDrawer");
expect(source).toContain('v-if="canUpdateShipment"');
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).toContain('"drug_shipments:update"');
expect(source).toContain(":readonly=\"isReadOnly\"");
});
it("hides the upload button in the detail attachment table", () => {
const source = readSource();
expect(source).toContain("<AttachmentList");
expect(source).toContain(":hide-uploader=\"true\"");
});
});
+157 -26
View File
@@ -1,29 +1,36 @@
<template>
<div class="page">
<div class="unified-shell">
<section class="unified-section">
<section v-loading="loading" class="unified-section">
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
<div class="ctms-section-actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
<div>
<div class="ctms-section-title">{{ TEXT.modules.drugShipments.detailTitle }}</div>
<div class="detail-subtitle">{{ TEXT.modules.drugShipments.detailSubtitle }}</div>
</div>
<div class="actions">
<el-button v-if="canUpdateShipment" type="primary" :disabled="isReadOnly" @click="goEdit" class="header-action-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
</div>
<div class="section-title-row">
<span class="ctms-section-title">{{ TEXT.common.labels.basicInfo }}</span>
<el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'">
<el-tag :type="statusType(detail.status)" effect="plain" round>
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
</el-tag>
</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 :type="detail.direction === 'SEND' ? 'primary' : 'warning'" size="small">
<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>
@@ -36,70 +43,162 @@
<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">
<AttachmentList
v-if="shipmentId"
v-if="shipmentId && studyId"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
:readonly="isReadOnly"
:hide-uploader="true"
/>
</section>
</div>
<DrugShipmentEditorDrawer
v-model="editorVisible"
:shipment-id="shipmentId || ''"
:readonly="isReadOnly"
@saved="handleEditorSaved"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { getDrugShipment } from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import DrugShipmentEditorDrawer from "./DrugShipmentEditorDrawer.vue";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const loading = ref(false);
const shipmentId = route.params.shipmentId as string;
const studyId = study.currentStudy?.id || "";
const editorVisible = ref(false);
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<any[]>([]);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canUpdateShipment = computed(() => isSystemAdmin(auth.user) || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["drug_shipments:update"]));
const siteActiveMap = computed(() =>
sites.value.reduce((acc: Record<string, boolean>, site: any) => {
if (site?.id) acc[site.id] = !!site.is_active;
return acc;
}, {})
);
const detail = reactive<any>({
const detail = reactive({
center_id: "",
direction: "",
status: "",
site_name: "",
ship_date: "",
receive_date: "",
quantity: null,
quantity: null as number | null,
batch_no: "",
carrier: "",
tracking_no: "",
remark: "",
});
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
const loadSites = async () => {
if (!studyId.value) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
if (!studyId || !shipmentId) return;
if (!studyId.value || !shipmentId.value) return;
loading.value = true;
try {
const { data } = await getDrugShipment(studyId, shipmentId);
Object.assign(detail, data);
const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(detail, {
center_id: data.center_id || "",
direction: data.direction || "",
status: data.status || "",
site_name: data.site_name || "",
ship_date: data.ship_date || "",
receive_date: data.receive_date || "",
quantity: data.quantity ?? null,
batch_no: data.batch_no || "",
carrier: data.carrier || "",
tracking_no: data.tracking_no || "",
remark: data.remark || "",
});
// 同步中心名称到面包屑
if (detail.site_name) {
study.setViewContext({ siteName: detail.site_name });
}
study.setViewContext({
siteName: detail.site_name || TEXT.common.fallback,
pageTitle: detail.tracking_no || detail.batch_no || TEXT.modules.drugShipments.detailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const goEdit = () => router.push(`/drug/shipments/${shipmentId}/edit`);
const goBack = () => router.push("/drug/shipments");
const goEdit = () => {
if (!canUpdateShipment.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
editorVisible.value = true;
};
onMounted(load);
const handleEditorSaved = () => {
load();
};
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";
}
};
watch(
() => [studyId.value, shipmentId.value],
async () => {
await loadSites();
load();
}
);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
@@ -109,10 +208,27 @@ onMounted(load);
gap: 0;
}
.detail-subtitle {
margin-top: 4px;
color: var(--ctms-text-secondary);
font-size: 13px;
}
.attachment-section {
padding: 0;
}
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
}
.section-title-row {
display: flex;
justify-content: space-between;
@@ -123,4 +239,19 @@ onMounted(load);
.section-title-inline {
margin-bottom: 12px;
}
.type-tag {
font-weight: 600;
border: none;
}
.type-send {
background-color: #fff7ed !important;
color: #ea580c !important;
}
.type-return {
background-color: #fefce8 !important;
color: #ca8a04 !important;
}
</style>
-273
View File
@@ -1,273 +0,0 @@
<template>
<div class="page">
<div class="unified-shell">
<section class="unified-section">
<div class="unified-section-header">
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.drugShipments.editTitle : TEXT.modules.drugShipments.newTitle }}</div>
<div class="ctms-section-actions">
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<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 :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<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-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.status" prop="status">
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly">
<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>
</el-form-item>
</el-col>
</el-row>
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
<el-row :gutter="24">
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" :disabled="isReadOnly" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :xl="6">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" :disabled="isReadOnly" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" :disabled="isReadOnly" />
</el-form-item>
<el-form-item>
<div class="actions">
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">
{{ TEXT.common.actions.save }}
</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</div>
</el-form-item>
</el-form>
</section>
<section class="unified-section">
<AttachmentList
v-if="isEdit && shipmentId"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
/>
<StateEmpty v-else :description="TEXT.modules.drugShipments.uploadHint" />
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createDrugShipment, getDrugShipment, 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 { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const formRef = ref();
const sites = ref<any[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const isEdit = computed(() => !!shipmentId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canMutate = computed(() => {
const operationKey = isEdit.value ? "drug_shipments:update" : "drug_shipments:create";
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
});
const isReadOnly = computed(() => !canMutate.value || (isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false));
const form = reactive({
center_id: "",
direction: "SEND",
ship_date: "",
receive_date: "",
quantity: null as number | null,
batch_no: "",
carrier: "",
tracking_no: "",
status: "PENDING",
remark: "",
});
const rules = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
ship_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
receive_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
quantity: [{ required: true, type: "number", message: TEXT.common.messages.required, trigger: "change" }],
batch_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
carrier: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
tracking_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
remark: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
};
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
if (!isEdit.value || !studyId.value || !shipmentId.value) return;
try {
const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(form, {
direction: data.direction || "SEND",
center_id: data.center_id || "",
ship_date: data.ship_date || "",
receive_date: data.receive_date || "",
quantity: data.quantity ?? null,
batch_no: data.batch_no || "",
carrier: data.carrier || "",
tracking_no: data.tracking_no || "",
status: data.status || "PENDING",
remark: data.remark || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!canMutate.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!studyId.value) return;
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
saving.value = true;
try {
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,
carrier: form.carrier || null,
tracking_no: form.tracking_no || null,
status: form.status,
remark: form.remark || null,
};
if (isEdit.value && shipmentId.value) {
await updateDrugShipment(studyId.value, shipmentId.value, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/drug/shipments/${shipmentId.value}`);
} else {
const { data } = await createDrugShipment(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/drug/shipments/${data.id}`);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/drug/shipments");
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.section-title {
margin: 0 0 24px;
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-primary);
border-left: 4px solid var(--ctms-primary);
padding-left: 12px;
}
.actions {
display: flex;
gap: 12px;
}
</style>
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./DrugShipments.vue"), "utf8");
describe("DrugShipments project permissions", () => {
it("hides edit and delete actions when the role lacks matching backend operation permissions", () => {
const source = readSource();
expect(source).toContain('["drug_shipments:create"]');
expect(source).toContain('["drug_shipments:update"]');
expect(source).toContain('["drug_shipments:delete"]');
expect(source).toContain('v-if="canUpdate"');
expect(source).toContain('v-if="canDelete"');
expect(source).toContain("if (!canUpdate.value)");
expect(source).toContain("if (!canDelete.value)");
});
it("uses flexible data columns so the table fills the available page width", () => {
const source = readSource();
expect(source).toContain('class="ctms-table shipment-table"');
expect(source).toContain('style="width: 100%"');
expect(source).toContain('table-layout="fixed"');
expect(source).toContain(':label="TEXT.common.labels.actions" width="130"');
expect(source).not.toMatch(/prop="(?:site_name|direction|ship_date|receive_date|quantity|batch_no|status|remark)"[^>]+(?:min-width|width)=/);
});
it("keeps edit and delete actions on one line", () => {
const source = readSource();
expect(source).toContain('class="shipment-actions"');
expect(source).toContain(".shipment-actions");
expect(source).toContain("white-space: nowrap;");
});
it("uses the compact pending upload area in the create/edit drawer", () => {
const source = readSource();
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="drug_shipment"');
expect(source).toContain(':entity-id="editingId"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(shipmentId)");
expect(source).toContain("uploadPending");
expect(source).toContain("group-dot-attachment");
expect(source).not.toContain("uploadAttachment");
expect(source).not.toContain("pendingFiles");
expect(source).not.toContain("upload-card-label");
});
it("keeps shipment execution fields optional while status is pending", () => {
const source = readSource();
expect(source).toContain("requiresShipmentDetails");
expect(source).toContain('status !== "PENDING"');
expect(source).not.toContain('ship_date: [{ required: true');
expect(source).not.toContain('batch_no: [{ required: true');
expect(source).not.toContain('carrier: [{ required: true');
expect(source).not.toContain('tracking_no: [{ required: true');
});
it("does not expose the removed returned status", () => {
const source = readSource();
expect(source).not.toContain("RETURNED");
expect(source).not.toContain("已回收");
});
it("requires receive date when shipment is signed", () => {
const source = readSource();
expect(source).toContain("requiresReceiveDate");
expect(source).toContain('status === "SIGNED"');
expect(source).not.toContain('receive_date: [{ required: true');
});
it("keeps remark optional unless shipment is exceptional", () => {
const source = readSource();
expect(source).toContain("requiresRemark");
expect(source).toContain('status === "EXCEPTION"');
expect(source).not.toContain('remark: [{ required: true');
});
});
+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;
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
describe("MaterialEquipment project permissions", () => {
it("projects material equipment create update and delete permissions into all write actions", () => {
const source = readSource();
expect(source).toContain("useAuthStore");
expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain('["material_equipments:create"]');
expect(source).toContain('["material_equipments:update"]');
expect(source).toContain('["material_equipments:delete"]');
expect(source).toContain('v-if="canCreate"');
expect(source).toContain('v-if="canUpdate"');
expect(source).toContain('v-if="canDelete"');
expect(source).toContain("if (!canDelete.value)");
});
it("keeps the visible table columns evenly distributed", () => {
const source = readSource();
expect(source).toContain('class="ctms-table equipment-table"');
expect(source).toContain('style="width: 100%"');
expect(source).toContain('table-layout="fixed"');
expect(source).toContain('label="操作"');
expect(source).not.toMatch(/<el-table-column[^>]+label="操作"[^>]+(?:width|min-width)=/);
});
it("opens the detail page from row clicks instead of a detail action button", () => {
const source = readSource();
expect(source).toContain('@row-click="onRowClick"');
expect(source).toContain(':row-class-name="equipmentRowClass"');
expect(source).toContain('name: "MaterialEquipmentDetail"');
expect(source).toContain("equipmentId: row.id");
expect(source).not.toContain(">详情</el-button>");
expect(source).toContain('@click.stop="openEdit(row)"');
expect(source).toContain('@click.stop="removeRow(row)"');
});
it("uploads qualification files as equipment attachments after saving the drawer form", () => {
const source = readSource();
const calibrationIndex = source.indexOf("校准设置");
const qualificationIndex = source.indexOf("附件");
expect(calibrationIndex).toBeGreaterThan(-1);
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':entity-id="editingId"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(equipmentId)");
expect(source).toContain("uploadPending");
expect(source).toContain("material_equipment");
expect(source).not.toContain("uploadAttachment");
expect(source).not.toContain("pendingFiles");
expect(source).not.toContain("qualificationRows");
expect(source).not.toContain("material_equipment_production_permit");
expect(source).not.toContain("material_equipment_tech_index");
expect(source).not.toContain("资质文件");
expect(source).not.toContain("生产许可证");
expect(source).not.toContain("技术指标");
expect(source).not.toContain("handleUploadChange");
});
});
+98 -154
View File
@@ -12,10 +12,18 @@
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<el-button type="primary" @click="openCreate">新建</el-button>
<el-button v-if="canCreate" type="primary" @click="openCreate">新建</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="ctms-table equipment-table" style="width: 100%" table-layout="fixed">
<el-table
v-loading="loading"
:data="rows"
class="ctms-table equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip />
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" show-overflow-tooltip />
@@ -25,10 +33,10 @@
{{ row.needCalibration ? "是" : "否" }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<el-table-column label="操作">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
<el-button v-if="canUpdate" link type="primary" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" @click.stop="removeRow(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
@@ -44,14 +52,14 @@
v-model="drawerVisible"
direction="rtl"
size="620px"
:close-on-click-modal="false"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="equipment-editor-drawer"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ editingId ? "编辑设备" : "新建设备" }}</div>
<div class="editor-subtitle">{{ editingId ? "修改设备基本信息与校准配置" : "添加新的设备记录到设备台账" }}</div>
</div>
</template>
@@ -93,49 +101,6 @@
</el-row>
</div>
<!-- 资质文件分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-file"></span>
资质文件
</div>
<el-row :gutter="16">
<el-col :span="12">
<div class="upload-card">
<div class="upload-card-label">生产许可证</div>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onProductionPermitChange">
<div v-if="!form.productionPermitFileName" class="upload-trigger">
<span class="upload-icon">📄</span>
<span class="upload-text">点击上传文件</span>
</div>
</el-upload>
<div v-if="form.productionPermitFileName" class="upload-result">
<span class="upload-result-icon"></span>
<span class="upload-result-name">{{ form.productionPermitFileName }}</span>
<el-button link type="danger" size="small" @click="form.productionPermitFileName = ''">移除</el-button>
</div>
</div>
</el-col>
<el-col :span="12">
<div class="upload-card">
<div class="upload-card-label">技术指标</div>
<el-upload :auto-upload="false" :show-file-list="false" :on-change="onTechIndexChange">
<div v-if="!form.techIndexFileName" class="upload-trigger">
<span class="upload-icon">📄</span>
<span class="upload-text">点击上传文件</span>
</div>
</el-upload>
<div v-if="form.techIndexFileName" class="upload-result">
<span class="upload-result-icon"></span>
<span class="upload-result-name">{{ form.techIndexFileName }}</span>
<el-button link type="danger" size="small" @click="form.techIndexFileName = ''">移除</el-button>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 校准设置分组 -->
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-calibration"></span>
@@ -161,12 +126,27 @@
<span>该设备无需定期校准</span>
</div>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-file"></span>
附件
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="study.currentStudy?.id || ''"
entity-type="material_equipment"
:entity-id="editingId"
:mode="'upload'"
:readonly="isFormReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" @click="saveForm">保存</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly" @click="saveForm">保存</el-button>
</div>
</template>
</el-drawer>
@@ -174,16 +154,22 @@
</template>
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import {
createMaterialEquipment,
deleteMaterialEquipment,
listMaterialEquipments,
updateMaterialEquipment,
} from "../../api/materialEquipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
interface EquipmentRow {
@@ -193,8 +179,6 @@ interface EquipmentRow {
unit: string;
brand: string;
origin: string;
productionPermitFileName: string;
techIndexFileName: string;
needCalibration: boolean;
calibrationCycleDays: number | null;
}
@@ -202,12 +186,16 @@ interface EquipmentRow {
type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]);
const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const editingId = ref("");
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const defaultForm: FormModel = {
name: "",
@@ -215,13 +203,21 @@ const defaultForm: FormModel = {
unit: "",
brand: "",
origin: "",
productionPermitFileName: "",
techIndexFileName: "",
needCalibration: true,
calibrationCycleDays: 30,
};
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]?.["material_equipments:create"]));
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value));
const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
@@ -262,8 +258,6 @@ const loadRows = async () => {
unit: item.unit || "",
brand: item.brand || "",
origin: item.origin || "",
productionPermitFileName: item.production_permit_file_name || "",
techIndexFileName: item.tech_index_file_name || "",
needCalibration: !!item.need_calibration,
calibrationCycleDays: item.calibration_cycle_days ?? null,
}));
@@ -281,12 +275,21 @@ const resetForm = () => {
};
const openCreate = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = "";
resetForm();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = true;
};
const openEdit = (row: EquipmentRow) => {
if (!canUpdate.value) {
ElMessage.warning("权限不足");
return;
}
editingId.value = row.id;
Object.assign(form, {
name: row.name,
@@ -294,18 +297,28 @@ const openEdit = (row: EquipmentRow) => {
unit: row.unit,
brand: row.brand,
origin: row.origin,
productionPermitFileName: row.productionPermitFileName,
techIndexFileName: row.techIndexFileName,
needCalibration: row.needCalibration,
calibrationCycleDays: row.calibrationCycleDays,
});
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = true;
};
const equipmentRowClass = ({ row }: { row: EquipmentRow }) => (row?.id ? "clickable-row" : "");
const onRowClick = (row: EquipmentRow) => {
if (!row?.id) return;
router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: row.id } });
};
const saveForm = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (isFormReadOnly.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await formRef.value?.validate().catch(() => false);
if (!ok) return;
const payload = {
@@ -314,28 +327,37 @@ const saveForm = async () => {
unit: form.unit.trim() || null,
brand: form.brand.trim(),
origin: form.origin.trim() || null,
production_permit_file_name: form.productionPermitFileName || null,
tech_index_file_name: form.techIndexFileName || null,
need_calibration: !!form.needCalibration,
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
};
saving.value = true;
try {
let equipmentId = editingId.value;
if (editingId.value) {
await updateMaterialEquipment(studyId, editingId.value, payload);
} else {
await createMaterialEquipment(studyId, payload);
const { data } = (await createMaterialEquipment(studyId, payload)) as any;
equipmentId = data?.id || "";
}
await attachmentPanelRef.value?.uploadPending(equipmentId);
await loadRows();
drawerDirtyGuard.syncBaseline();
drawerVisible.value = false;
ElMessage.success("保存成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed);
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || e?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const removeRow = async (row: EquipmentRow) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDelete.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await ElMessageBox.confirm("确认删除该条设备记录吗?", "提示", { type: "warning" }).catch(() => null);
if (!ok) return;
try {
@@ -347,18 +369,6 @@ const removeRow = async (row: EquipmentRow) => {
}
};
const handleUploadChange = (file: UploadFile, field: "productionPermitFileName" | "techIndexFileName") => {
form[field] = file.name;
};
const onProductionPermitChange = (file: UploadFile) => {
handleUploadChange(file, "productionPermitFileName");
};
const onTechIndexChange = (file: UploadFile) => {
handleUploadChange(file, "techIndexFileName");
};
const handleSearch = () => {
loadRows();
};
@@ -406,10 +416,18 @@ watch(
}
/* ========== 抽屉头部 ========== */
:deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.equipment-editor-drawer > .el-drawer__body) {
padding: 0 20px 4px;
}
.editor-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.editor-title {
@@ -419,15 +437,9 @@ watch(
line-height: 1.2;
}
.editor-subtitle {
font-size: 13px;
color: var(--ctms-text-secondary);
font-weight: 400;
}
/* ========== 表单整体 ========== */
.equipment-form {
padding: 4px 4px 0 0;
padding: 0 4px 0 0;
}
/* ========== 分组卡片 ========== */
@@ -469,7 +481,7 @@ watch(
background: #3b82f6;
}
/* 资质文件 - 琥珀色 */
/* 件 - 琥珀色 */
.group-dot-file {
background: #f0ad2c;
}
@@ -479,74 +491,6 @@ watch(
background: #22c55e;
}
/* ========== 上传卡片 ========== */
.upload-card {
border: 1px dashed #d0dced;
border-radius: 8px;
padding: 12px 14px;
background: #ffffff;
transition: all 0.2s ease;
min-height: 60px;
}
.upload-card:hover {
border-color: var(--ctms-primary);
background: #f8faff;
}
.upload-card-label {
font-size: 12px;
font-weight: 600;
color: #4a6283;
margin-bottom: 8px;
}
.upload-trigger {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 0;
}
.upload-icon {
font-size: 18px;
line-height: 1;
}
.upload-text {
font-size: 13px;
color: var(--ctms-text-secondary);
transition: color 0.2s ease;
}
.upload-trigger:hover .upload-text {
color: var(--ctms-primary);
}
.upload-result {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
}
.upload-result-icon {
font-size: 14px;
line-height: 1;
}
.upload-result-name {
font-size: 12px;
color: var(--ctms-text-regular);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
/* ========== 校准提示 ========== */
.calibration-hint {
display: flex;
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./StartupMeetingAuth.vue"), "utf8");
describe("StartupMeetingAuth permissions", () => {
it("does not load project members unless the role can read project members", () => {
const source = readSource();
expect(source).toContain('can("project.members.list")');
expect(source).toContain("if (canReadMembers.value)");
});
it("does not create kickoff records from row click unless startup auth create permission is granted", () => {
const source = readSource();
expect(source).toContain('can("startup.auth.create")');
expect(source).toContain("canCreateAuth");
expect(source).toContain("if (!canCreateAuth.value)");
});
});
+18 -5
View File
@@ -45,17 +45,22 @@ import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const { can } = usePermission();
const kickoffItems = ref<any[]>([]);
const sites = ref<any[]>([]);
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const loading = ref(false);
const isAdmin = computed(() => auth.user?.is_admin);
const isAdmin = computed(() => isSystemAdmin(auth.user));
const canReadMembers = computed(() => can("project.members.list"));
const canCreateAuth = computed(() => can("startup.auth.create"));
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
@@ -85,7 +90,7 @@ const contactLabel = (row: any) => {
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || TEXT.common.fallback)
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
};
@@ -118,15 +123,19 @@ const loadKickoffs = async () => {
const requests = [
fetchSites(studyId, { limit: 500 }),
listKickoffs(studyId),
listMembers(studyId, { limit: 500 }),
];
if (canReadMembers.value) {
requests.push(listMembers(studyId, { limit: 500 }));
}
if (isAdmin.value) {
requests.push(fetchUsers({ limit: 500 }));
}
const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all(requests) as any[];
const [sitesResp, kickoffResp, ...optionalResponses] = await Promise.all(requests) as any[];
sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || [];
kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || [];
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
const membersResp = canReadMembers.value ? optionalResponses.shift() : null;
members.value = membersResp?.data ? (Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || []) : [];
const usersResp = isAdmin.value ? optionalResponses.shift() : null;
users.value = usersResp?.data?.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -147,6 +156,10 @@ const onKickoffRowClick = async (row: any) => {
goKickoffDetail(row.meeting_id);
return;
}
if (!canCreateAuth.value) {
ElMessage.warning("权限不足");
return;
}
try {
const { data } = await createKickoff(studyId, { site_id: row.site_id }) as any;
goKickoffDetail(data.id);
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentDetail.vue"), "utf8");
describe("MaterialEquipmentDetail edit drawer", () => {
it("shows an edit action and opens the equipment editor drawer on the detail page", () => {
const source = readSource();
expect(source).toContain("<MaterialEquipmentEditorDrawer");
expect(source).toContain('v-if="canUpdateEquipment"');
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).toContain('"material_equipments:update"');
});
it("places attachments below calibration settings and renders them as one attachment table", () => {
const source = readSource();
const calibrationIndex = source.indexOf("校准设置");
const qualificationIndex = source.indexOf("TEXT.common.labels.attachments");
expect(calibrationIndex).toBeGreaterThan(-1);
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
expect(source).toContain("<AttachmentList");
expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':hide-uploader="true"');
expect(source).toContain(':refresh-key="attachmentRefreshKey"');
expect(source).toContain("attachmentRefreshKey.value += 1");
expect(source).not.toContain("fetchAttachments");
expect(source).not.toContain("deleteAttachment");
expect(source).not.toContain("formatFileSize");
expect(source).not.toContain("qualificationAttachments");
expect(source).not.toContain('class="attachment-table qualification-table"');
expect(source).not.toContain("previewVisible");
expect(source).not.toContain("getDownloadUrl");
expect(source).not.toContain("removeAttachment");
expect(source).toContain("material_equipment");
expect(source).not.toContain("material_equipment_production_permit");
expect(source).not.toContain("material_equipment_tech_index");
expect(source).not.toContain("资质文件");
expect(source).not.toContain("生产许可证");
expect(source).not.toContain("技术指标");
expect(source).not.toContain('label="生产许可证">{{ displayValue(detail.production_permit_file_name) }}');
expect(source).not.toContain('label="技术指标">{{ displayValue(detail.tech_index_file_name) }}');
});
});
@@ -0,0 +1,190 @@
<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>
</div>
<div class="actions">
<el-button v-if="canUpdateEquipment" type="primary" @click="goEdit" class="header-action-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</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>
<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>
<section class="unified-section">
<div class="ctms-section-title section-title-inline">{{ TEXT.common.labels.attachments }}</div>
<AttachmentList
v-if="equipmentId && studyId"
:study-id="studyId"
entity-type="material_equipment"
:entity-id="equipmentId"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</section>
<MaterialEquipmentEditorDrawer
v-model="editorVisible"
:equipment-id="equipmentId || ''"
:readonly="!canUpdateEquipment"
@saved="handleEditorSaved"
/>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
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 { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
const route = useRoute();
const auth = useAuthStore();
const study = useStudyStore();
const loading = ref(false);
const editorVisible = ref(false);
const attachmentRefreshKey = ref(0);
const equipmentId = computed(() => route.params.equipmentId as string | undefined);
const studyId = computed(() => study.currentStudy?.id || "");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canUpdateEquipment = computed(() => isSystemAdmin(auth.user) || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const detail = reactive({
name: "",
spec_model: "",
unit: "",
brand: "",
origin: "",
need_calibration: false,
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("权限不足");
return;
}
editorVisible.value = true;
};
const handleEditorSaved = () => {
load();
attachmentRefreshKey.value += 1;
};
const load = async () => {
if (!studyId.value || !equipmentId.value) return;
loading.value = true;
try {
const { data } = await getMaterialEquipment(studyId.value, equipmentId.value);
Object.assign(detail, {
name: data.name || "",
spec_model: data.spec_model || "",
unit: data.unit || "",
brand: data.brand || "",
origin: data.origin || "",
need_calibration: !!data.need_calibration,
calibration_cycle_days: data.calibration_cycle_days ?? null,
});
study.setViewContext({
pageTitle: detail.name || TEXT.modules.materialEquipment.detailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
watch(
() => [studyId.value, equipmentId.value],
() => {
load();
}
);
onMounted(load);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.detail-subtitle {
margin-top: 4px;
color: var(--ctms-text-secondary);
font-size: 13px;
}
.section-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
}
.section-title-inline {
margin-bottom: 12px;
}
</style>
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipmentEditorDrawer.vue"), "utf8");
describe("MaterialEquipmentEditorDrawer attachments", () => {
it("queues qualification files and uploads them as business attachments on save", () => {
const source = readSource();
const calibrationIndex = source.indexOf("校准设置");
const qualificationIndex = source.indexOf("附件");
expect(calibrationIndex).toBeGreaterThan(-1);
expect(qualificationIndex).toBeGreaterThan(calibrationIndex);
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain('entity-type="material_equipment"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain("attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.equipmentId)");
expect(source).toContain("uploadPending");
expect(source).toContain("material_equipment");
expect(source).not.toContain("uploadAttachment");
expect(source).not.toContain("pendingFiles");
expect(source).not.toContain("qualificationRows");
expect(source).not.toContain("material_equipment_production_permit");
expect(source).not.toContain("material_equipment_tech_index");
expect(source).not.toContain("资质文件");
expect(source).not.toContain("生产许可证");
expect(source).not.toContain("技术指标");
expect(source).not.toContain("handleUploadChange");
});
});
@@ -0,0 +1,344 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="620px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="equipment-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">编辑设备</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="equipment-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
基本信息
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" :disabled="readonly" placeholder="请输入设备名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="specModel">
<el-input v-model="form.specModel" :disabled="readonly" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" :disabled="readonly" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="品牌" prop="brand">
<el-input v-model="form.brand" :disabled="readonly" placeholder="请输入品牌" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="产地" prop="origin">
<el-input v-model="form.origin" :disabled="readonly" placeholder="请输入产地" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-calibration"></span>
校准设置
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="是否需要校准" prop="needCalibration">
<el-select v-model="form.needCalibration" :disabled="readonly" placeholder="请选择" class="full-width">
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="form.needCalibration" :span="12">
<el-form-item label="校准周期(天)" prop="calibrationCycleDays">
<el-input-number v-model="form.calibrationCycleDays" :disabled="readonly" :min="1" :precision="0" class="full-width" />
</el-form-item>
</el-col>
</el-row>
<div v-if="!form.needCalibration" class="calibration-hint">该设备无需定期校准</div>
</div>
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-file"></span>
附件
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="material_equipment"
:entity-id="equipmentId"
:mode="'upload'"
:readonly="readonly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="readonly" @click="saveForm">保存</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { getMaterialEquipment, updateMaterialEquipment } from "../../api/materialEquipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { useStudyStore } from "../../store/study";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
type FormModel = {
name: string;
specModel: string;
unit: string;
brand: string;
origin: string;
needCalibration: boolean;
calibrationCycleDays: number | null;
};
const props = defineProps<{ modelValue: boolean; equipmentId: string; readonly?: boolean }>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
(event: "saved"): void;
}>();
const study = useStudyStore();
const formRef = ref<FormInstance>();
const saving = ref(false);
const readonly = ref(false);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive<FormModel>({
name: "",
specModel: "",
unit: "",
brand: "",
origin: "",
needCalibration: true,
calibrationCycleDays: 30,
});
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
specModel: [{ required: true, message: "请输入规格型号", trigger: "blur" }],
brand: [{ required: true, message: "请输入品牌", trigger: "blur" }],
needCalibration: [{ required: true, message: "请选择是否需要校准", trigger: "change" }],
calibrationCycleDays: [
{
validator: (_rule, value, callback) => {
if (form.needCalibration && (!value || value < 1)) {
callback(new Error("请填写校准周期"));
return;
}
callback();
},
trigger: "change",
},
],
};
const load = async () => {
if (!studyId.value || !props.equipmentId) return;
readonly.value = !!props.readonly;
try {
const { data } = await getMaterialEquipment(studyId.value, props.equipmentId);
Object.assign(form, {
name: data.name || "",
specModel: data.spec_model || "",
unit: data.unit || "",
brand: data.brand || "",
origin: data.origin || "",
needCalibration: !!data.need_calibration,
calibrationCycleDays: data.calibration_cycle_days ?? null,
});
formRef.value?.clearValidate();
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.loadFailed);
}
};
const saveForm = async () => {
if (!studyId.value || !props.equipmentId) return;
if (readonly.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await formRef.value?.validate().catch(() => false);
if (!ok) return;
saving.value = true;
try {
await updateMaterialEquipment(studyId.value, props.equipmentId, {
name: form.name.trim(),
spec_model: form.specModel.trim(),
unit: form.unit.trim() || null,
brand: form.brand.trim(),
origin: form.origin.trim() || null,
need_calibration: !!form.needCalibration,
calibration_cycle_days: form.needCalibration ? form.calibrationCycleDays : null,
});
await attachmentPanelRef.value?.uploadPending(props.equipmentId);
drawerDirtyGuard.syncBaseline();
emit("saved");
emit("update:modelValue", false);
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => [props.modelValue, props.equipmentId, props.readonly] as const,
([visible]) => {
readonly.value = !!props.readonly;
if (visible) load();
},
{ immediate: true }
);
watch(
() => form.needCalibration,
(need) => {
if (!need) form.calibrationCycleDays = null;
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
}
);
</script>
<style scoped>
:deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.equipment-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;
}
.equipment-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-file {
background: #f0ad2c;
}
.group-dot-calibration {
background: #22c55e;
}
.calibration-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
font-size: 13px;
color: #166534;
margin-bottom: 8px;
}
.full-width {
width: 100%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.equipment-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.equipment-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
padding-bottom: 4px;
}
</style>
+124 -260
View File
@@ -6,16 +6,13 @@
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div>
<div class="ctms-section-actions">
<el-button
v-if="!editMode"
v-if="canUpdateAuth"
type="primary"
:disabled="loading || isReadOnly"
@click="startEdit"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-else type="primary" :loading="saving" @click="saveEdit">{{ TEXT.common.actions.save }}</el-button>
<el-button v-if="editMode" :disabled="saving" @click="cancelEdit">{{ TEXT.common.actions.cancel }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<el-descriptions :column="2" border>
@@ -29,14 +26,7 @@
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker
v-if="editMode"
v-model="draft.kickoff_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
/>
<span v-else>{{ displayDate(detail.kickoff_date) }}</span>
{{ displayDate(detail.kickoff_date) }}
</el-descriptions-item>
</el-descriptions>
</section>
@@ -44,116 +34,96 @@
<section class="unified-section training-section">
<div class="training-header">
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
<el-button v-if="canCreateAuth" type="primary" size="small" :disabled="isReadOnly" @click="startTrainingAdd">
{{ TEXT.common.actions.add }}
</el-button>
</div>
<el-table :data="trainingRows" v-loading="loadingTraining" style="width: 100%" class="ctms-table" table-layout="fixed">
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%" class="ctms-table" table-layout="fixed">
<el-table-column prop="name" :label="TEXT.common.fields.name" show-overflow-tooltip>
<template #default="scope">
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.name" size="small" />
<span v-else>{{ scope.row.name || TEXT.common.fallback }}</span>
{{ scope.row.name || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip>
<template #default="scope">
<el-input v-if="isTrainingEditing(scope.row)" v-model="trainingRowDraft.role" size="small" />
<span v-else>{{ scope.row.role || TEXT.common.fallback }}</span>
{{ scope.row.role || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.trained">
<template #default="scope">
<el-checkbox
v-if="isTrainingEditing(scope.row)"
v-model="trainingRowDraft.trained"
:disabled="isReadOnly"
@click.stop
/>
<el-checkbox
v-else
v-model="scope.row.trained"
:disabled="isReadOnly"
@change="toggleTraining(scope.row)"
@click.stop
/>
<div class="status-with-date">
<span>{{ trainingStatusLabel(scope.row) }}</span>
<span v-if="scope.row.trained && scope.row.trained_date" class="status-date-small">
{{ displayDate(scope.row.trained_date) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.authorized">
<template #default="scope">
<el-checkbox
v-if="isTrainingEditing(scope.row)"
v-model="trainingRowDraft.authorized"
:disabled="isReadOnly"
@click.stop
/>
<el-checkbox
v-else
v-model="scope.row.authorized"
:disabled="isReadOnly"
@change="toggleTraining(scope.row)"
@click.stop
/>
<div class="status-with-date">
<span>{{ authorizationStatusLabel(scope.row) }}</span>
<span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date-small">
{{ displayDate(scope.row.authorized_date) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="140">
<el-table-column v-if="canCreateAuth || canUpdateAuth || canDeleteAuth" :label="TEXT.common.labels.actions" width="140">
<template #default="scope">
<template v-if="isTrainingEditing(scope.row)">
<el-button link type="primary" size="small" :loading="savingTraining" @click.stop="saveTraining(scope.row)">
{{ TEXT.common.actions.save }}
</el-button>
<el-button link size="small" :disabled="savingTraining" @click.stop="cancelTrainingEdit">
{{ TEXT.common.actions.cancel }}
</el-button>
</template>
<template v-else>
<el-button link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
</template>
<el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<div
class="training-add-row"
:class="{ disabled: newTrainingActive || savingTraining || isReadOnly }"
@click="startTrainingAdd"
>
<span class="training-add-icon">+</span>
</div>
<StateEmpty
v-if="!loadingTraining && trainingItems.length === 0 && !newTrainingActive"
:description="TEXT.modules.startupMeetingAuth.emptyTraining"
/>
</section>
<section v-if="meetingId" class="unified-section attachment-section">
<div class="attachment-grid">
<AttachmentList
v-for="group in kickoffAttachmentGroups"
:key="group.entityType"
:study-id="studyId"
:entity-type="group.entityType"
:entity-id="meetingId"
:title="group.title"
/>
</div>
<AttachmentList
:study-id="studyId"
entity-type="startup_kickoff"
:entity-id="meetingId"
:entity-groups="kickoffAttachmentGroups"
:title="TEXT.common.labels.attachments"
:readonly="true"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</section>
</div>
<KickoffEditorDrawer
v-model="editorVisible"
:meeting-id="meetingId"
:study-id="studyId"
:site-name="siteInfo.name"
:owner-label="ownerLabel"
:readonly="isReadOnly"
@saved="handleEditorSaved"
/>
<TrainingEditorDrawer
v-model="trainingEditorVisible"
:record-id="editingTrainingId"
:study-id="studyId"
:sites="sites"
:initial-site-name="siteInfo.name"
@saved="handleTrainingEditorSaved"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import {
getKickoff,
updateKickoff,
listTrainingAuthorizations,
createTrainingAuthorization,
updateTrainingAuthorization,
deleteTrainingAuthorization,
} from "../../api/startup";
import { fetchSites } from "../../api/sites";
@@ -161,62 +131,43 @@ import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT, requiredMessage } from "../../locales";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import KickoffEditorDrawer from "./KickoffEditorDrawer.vue";
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const loadingTraining = ref(false);
const saving = ref(false);
const editMode = ref(false);
const editorVisible = ref(false);
const trainingEditorVisible = ref(false);
const editingTrainingId = ref<string | undefined>();
const attachmentRefreshKey = ref(0);
const meetingId = route.params.meetingId as string;
const studyId = study.currentStudy?.id || "";
const siteInfo = reactive<{ name: string; contact?: string | null; is_active?: boolean }>({ name: "" });
const sites = ref<any[]>([]);
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const isAdmin = computed(() => auth.user?.is_admin);
const canReadMembers = computed(() => can("project.members.list"));
const canCreateAuth = computed(() => can("startup.auth.create"));
const canUpdateAuth = computed(() => can("startup.auth.update"));
const canDeleteAuth = computed(() => can("startup.auth.delete"));
const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff_minutes", title: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", title: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
{ entityType: "startup_kickoff_ppt", title: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
{ entityType: "startup_kickoff", title: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
{ entityType: "startup_kickoff_minutes", label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel },
{ entityType: "startup_kickoff_signin", label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel },
{ entityType: "startup_kickoff_ppt", label: TEXT.modules.startupMeetingAuth.kickoffPptLabel },
{ entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
];
const detail = reactive<any>({
kickoff_date: "",
attendees: [],
});
const draft = reactive({
kickoff_date: "",
});
const trainingItems = ref<any[]>([]);
const trainingEditingRowId = ref<string | null>(null);
const newTrainingActive = ref(false);
const savingTraining = ref(false);
const trainingRowDraft = reactive({
name: "",
role: "",
trained: false,
authorized: false,
});
const trainingRows = computed(() => {
if (!newTrainingActive.value) return trainingItems.value;
return [
...trainingItems.value,
{
id: "__new__",
__isNew: true,
name: "",
role: "",
trained: false,
authorized: false,
},
];
});
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
@@ -247,6 +198,8 @@ const statusLabel = computed(() =>
);
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
const isReadOnly = computed(() => siteInfo.is_active === false);
const trainingStatusLabel = (row: any) => (row?.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
const authorizationStatusLabel = (row: any) => (row?.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
const load = async () => {
if (!studyId || !meetingId) return;
@@ -254,17 +207,20 @@ const load = async () => {
try {
const { data } = await getKickoff(studyId, meetingId);
Object.assign(detail, data);
if (!editMode.value) {
draft.kickoff_date = data.kickoff_date || "";
}
if (data?.site_id) {
const { data: sitesData } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
sites.value = list;
const matched = list.find((site: any) => site.id === data.site_id);
Object.assign(siteInfo, matched || { name: "" });
} else {
Object.assign(siteInfo, { name: "" });
}
const siteName = siteInfo.name || TEXT.common.fallback;
study.setViewContext({
siteName,
pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -273,10 +229,10 @@ const load = async () => {
};
const loadTraining = async () => {
if (!studyId) return;
if (!studyId || !siteInfo.name) return;
loadingTraining.value = true;
try {
const { data } = await listTrainingAuthorizations(studyId);
const { data } = await listTrainingAuthorizations(studyId, { site_name: siteInfo.name });
trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
@@ -285,88 +241,24 @@ const loadTraining = async () => {
}
};
const toggleTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
const startTrainingEdit = (row: any) => {
if (isReadOnly.value) return;
if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (!studyId) return;
try {
await updateTrainingAuthorization(studyId, row.id, {
trained: row.trained,
authorized: row.authorized,
});
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
loadTraining();
}
};
const resetTrainingDraft = (row?: any) => {
trainingRowDraft.name = row?.name || "";
trainingRowDraft.role = row?.role || "";
trainingRowDraft.trained = !!row?.trained;
trainingRowDraft.authorized = !!row?.authorized;
};
const isTrainingEditing = (row: any) => {
if (row?.__isNew) return newTrainingActive.value;
return trainingEditingRowId.value === row?.id;
};
const startTrainingEdit = (row: any) => {
if (savingTraining.value || isReadOnly.value) return;
newTrainingActive.value = false;
trainingEditingRowId.value = row?.id || null;
resetTrainingDraft(row);
editingTrainingId.value = row?.id || undefined;
trainingEditorVisible.value = true;
};
const startTrainingAdd = () => {
if (newTrainingActive.value || savingTraining.value || isReadOnly.value) return;
trainingEditingRowId.value = null;
newTrainingActive.value = true;
resetTrainingDraft();
};
const cancelTrainingEdit = () => {
trainingEditingRowId.value = null;
newTrainingActive.value = false;
resetTrainingDraft();
};
const saveTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
if (isReadOnly.value) return;
if (!canCreateAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (!studyId) return;
if (!trainingRowDraft.name.trim()) {
ElMessage.error(requiredMessage(TEXT.common.fields.name));
return;
}
savingTraining.value = true;
try {
const payload = {
name: trainingRowDraft.name.trim(),
role: (trainingRowDraft.role || "").trim() || null,
trained: trainingRowDraft.trained,
authorized: trainingRowDraft.authorized,
};
if (row?.__isNew) {
await createTrainingAuthorization(studyId, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
} else {
await updateTrainingAuthorization(studyId, row.id, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
}
cancelTrainingEdit();
loadTraining();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
savingTraining.value = false;
}
editingTrainingId.value = undefined;
trainingEditorVisible.value = true;
};
const removeTraining = async (row: any) => {
@@ -374,6 +266,10 @@ const removeTraining = async (row: any) => {
ElMessage.warning("中心已停用");
return;
}
if (!canDeleteAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
@@ -391,43 +287,29 @@ const startEdit = () => {
ElMessage.warning("中心已停用");
return;
}
editMode.value = true;
draft.kickoff_date = detail.kickoff_date || "";
};
const cancelEdit = () => {
editMode.value = false;
draft.kickoff_date = detail.kickoff_date || "";
};
const saveEdit = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (!studyId || !meetingId) return;
saving.value = true;
try {
const payload = {
kickoff_date: draft.kickoff_date || null,
};
const { data } = await updateKickoff(studyId, meetingId, payload);
Object.assign(detail, data);
editMode.value = false;
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
editorVisible.value = true;
};
const goBack = () => router.push("/startup/meeting-auth");
const handleEditorSaved = () => {
load();
attachmentRefreshKey.value += 1;
};
const handleTrainingEditorSaved = () => {
loadTraining();
};
onMounted(async () => {
if (studyId) {
const membersReq = listMembers(studyId, { limit: 500 });
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
let membersReq: Promise<any> = Promise.resolve(null);
if (canReadMembers.value) {
membersReq = listMembers(studyId, { limit: 500 });
}
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
@@ -437,7 +319,7 @@ onMounted(async () => {
}
}
await load();
loadTraining();
await loadTraining();
});
</script>
@@ -461,6 +343,7 @@ onMounted(async () => {
.training-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
@@ -470,34 +353,15 @@ onMounted(async () => {
color: var(--ctms-text-main);
}
.training-add-row {
border: 1px dashed var(--ctms-border-color);
border-top: none;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctms-text-secondary);
cursor: pointer;
}
.training-add-row:hover {
color: var(--ctms-text-main);
}
.training-add-row.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.training-add-icon {
font-size: 20px;
line-height: 1;
}
.attachment-grid {
display: flex;
.status-with-date {
display: inline-flex;
flex-direction: column;
gap: 16px;
gap: 2px;
line-height: 1.2;
}
.status-date-small {
font-size: 12px;
color: var(--ctms-text-secondary);
}
</style>
@@ -0,0 +1,304 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="640px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="startup-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ TEXT.modules.startupMeetingAuth.kickoffEditTitle }}</div>
</div>
</template>
<StateError v-if="drawerErrorMessage" :description="drawerErrorMessage">
<template #action>
<el-button type="primary" @click="reloadEditingDetail">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="drawerLoading" :rows="6" />
<el-form v-else ref="formRef" :model="form" label-position="top" class="startup-editor-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site">
<el-input :model-value="siteName || TEXT.common.fallback" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
<el-input :model-value="ownerLabel || TEXT.common.fallback" disabled />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker
v-model="form.kickoff_date"
:disabled="isFormReadOnly"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="full-width"
/>
</el-form-item>
<div v-if="readonly" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
<div v-if="meetingId" 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="studyId"
entity-type="startup_kickoff"
:entity-id="meetingId"
:entity-groups="kickoffAttachmentGroups"
:mode="'upload'"
:readonly="isFormReadOnly"
/>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly || drawerLoading" @click="saveForm">
{{ TEXT.common.actions.save }}
</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance } from "element-plus";
import { getKickoff, updateKickoff } from "../../api/startup";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
const props = defineProps<{
modelValue: boolean;
meetingId: string;
studyId: string;
siteName?: string;
ownerLabel?: string;
readonly?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
saved: [meetingId: string];
}>();
const { can } = usePermission();
const canUpdateAuth = computed(() => can("startup.auth.update"));
const drawerLoading = ref(false);
const drawerErrorMessage = ref("");
const saving = ref(false);
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const form = reactive({
kickoff_date: "",
});
const isFormReadOnly = computed(() => !canUpdateAuth.value || !!props.readonly);
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
attachments: attachmentPanelRef.value?.pendingSnapshot() || [],
}));
const kickoffAttachmentGroups = [
{ label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel, entityType: "startup_kickoff_minutes" },
{ label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel, entityType: "startup_kickoff_signin" },
{ label: TEXT.modules.startupMeetingAuth.kickoffPptLabel, entityType: "startup_kickoff_ppt" },
{ label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel, entityType: "startup_kickoff" },
];
const resetForm = () => {
form.kickoff_date = "";
drawerErrorMessage.value = "";
formRef.value?.clearValidate();
};
const loadEditingDetail = async () => {
if (!props.studyId || !props.meetingId) return;
drawerLoading.value = true;
drawerErrorMessage.value = "";
try {
const { data } = await getKickoff(props.studyId, props.meetingId);
form.kickoff_date = data.kickoff_date || "";
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
drawerErrorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
drawerLoading.value = false;
}
};
const reloadEditingDetail = () => {
loadEditingDetail();
};
const saveForm = async () => {
if (!props.studyId || !props.meetingId) return;
if (isFormReadOnly.value) {
ElMessage.warning(props.readonly ? "中心已停用" : "权限不足");
return;
}
saving.value = true;
try {
await updateKickoff(props.studyId, props.meetingId, {
kickoff_date: form.kickoff_date || null,
});
await attachmentPanelRef.value?.uploadPending(props.meetingId);
drawerDirtyGuard.syncBaseline();
emit("update:modelValue", false);
emit("saved", props.meetingId);
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => props.modelValue,
async (visible) => {
if (!visible) return;
resetForm();
await loadEditingDetail();
},
);
</script>
<style scoped>
:deep(.startup-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 20px 8px;
}
:deep(.startup-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;
}
.startup-editor-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-attachment {
background: #22c55e;
}
.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%;
}
.attachment-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 20px 16px;
}
</style>
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readForm = () => readFileSync(resolve(__dirname, "./KickoffForm.vue"), "utf8");
const readDetail = () => readFileSync(resolve(__dirname, "./KickoffDetail.vue"), "utf8");
describe("Kickoff member permissions", () => {
it("does not load project members in the form unless the role can read project members", () => {
const source = readForm();
expect(source).toContain('can("project.members.list")');
expect(source).toContain("if (canReadMembers.value)");
});
it("does not load project members in the detail page unless the role can read project members", () => {
const source = readDetail();
expect(source).toContain('can("project.members.list")');
expect(source).toContain("if (canReadMembers.value)");
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
describe("startup auth editor drawer attachments", () => {
it("uses upload cards for kickoff attachments instead of full attachment tables", () => {
const source = read("./KickoffEditorDrawer.vue");
expect(source).toContain("AttachmentList");
expect(source).toContain('ref="attachmentPanelRef"');
expect(source).toContain(':mode="\'upload\'"');
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
expect(source).toContain("attachments: attachmentPanelRef.value?.pendingSnapshot() || []");
expect(source).toContain("await attachmentPanelRef.value?.uploadPending(props.meetingId)");
expect(source).toContain('entityType: "startup_kickoff_minutes"');
expect(source).toContain('entityType: "startup_kickoff_signin"');
expect(source).toContain('entityType: "startup_kickoff_ppt"');
expect(source).toContain('entityType: "startup_kickoff"');
expect(source).not.toContain("PendingAttachmentUpload");
expect(source).not.toContain("attendees");
expect(source).not.toContain("parseAttendees");
expect(source).not.toContain("attendeesPlaceholder");
});
it("does not expose attachments in the training authorization editor", () => {
const source = read("./TrainingEditorDrawer.vue");
expect(source).not.toContain("PendingAttachmentUpload");
expect(source).not.toContain("AttachmentList");
expect(source).not.toContain("training_authorization");
expect(source).not.toContain("attachments:");
expect(source).not.toContain("uploadPending");
});
it("supports batch creating and single-row updating training authorizations in the same drawer", () => {
const source = read("./TrainingEditorDrawer.vue");
expect(source).toContain("recordId?: string");
expect(source).toContain('editorTitle');
expect(source).toContain("createTrainingAuthorization");
expect(source).toContain("updateTrainingAuthorization");
expect(source).toContain("const isCreateMode = computed(() => !props.recordId)");
expect(source).toContain("const canCreateAuth = computed(() => can(\"startup.auth.create\"))");
expect(source).toContain("const canSaveAuth = computed(() => (isCreateMode.value ? canCreateAuth.value : canUpdateAuth.value))");
expect(source).toContain("batchRows");
expect(source).toContain("addBatchRow");
expect(source).toContain("removeBatchRow");
expect(source).toContain("for (const row of batchRows.value)");
});
it("keeps training authorization editor compact and hides site input while preserving site context", () => {
const source = read("./TrainingEditorDrawer.vue");
expect(source).not.toContain(':label="TEXT.common.fields.site"');
expect(source).not.toContain('prop="site_name"');
expect(source).toContain("site_name: props.initialSiteName || form.site_name || null");
expect(source).toContain('class="compact-row-grid"');
expect(source).toContain('class="compact-field compact-field--name"');
expect(source).toContain('class="compact-field compact-field--status"');
expect(source).toContain('class="compact-field compact-field--remark"');
});
it("uses a single-layer batch editor layout for training authorization creation", () => {
const source = read("./TrainingEditorDrawer.vue");
expect(source).toContain('class="form-section training-batch-editor"');
expect(source).toContain('class="batch-editor"');
expect(source).toContain('class="batch-entry"');
expect(source).toContain('class="status-inline"');
expect(source).toContain('class="status-date"');
expect(source).not.toContain('class="batch-row"');
expect(source).not.toContain(".batch-row {");
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const read = (relativePath: string) => readFileSync(resolve(__dirname, relativePath), "utf8");
describe("startup auth form/detail permissions", () => {
it("maps startup auth actions to backend operation permissions", () => {
const source = read("../../utils/permission.ts");
expect(source).toContain('"startup.auth.create": "startup_auth:create"');
expect(source).toContain('"startup.auth.update": "startup_auth:update"');
expect(source).toContain('"startup.auth.delete": "startup_auth:delete"');
});
it("uses create/update permissions in kickoff and training forms", () => {
const kickoffForm = read("./KickoffForm.vue");
const trainingForm = read("./TrainingForm.vue");
expect(kickoffForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
expect(kickoffForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
expect(kickoffForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
expect(kickoffForm).toContain("if (!canSaveAuth.value)");
expect(trainingForm).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
expect(trainingForm).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
expect(trainingForm).toContain("const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value))");
expect(trainingForm).toContain("if (!canSaveAuth.value)");
});
it("opens kickoff detail edits in a drawer and keeps training actions split by backend operations", () => {
const source = read("./KickoffDetail.vue");
expect(source).toContain("<KickoffEditorDrawer");
expect(source).toContain("<TrainingEditorDrawer");
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("trainingEditorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).toContain(":refresh-key=\"attachmentRefreshKey\"");
expect(source).toContain("attachmentRefreshKey.value += 1");
expect(source).toContain("const handleTrainingEditorSaved = () =>");
expect(source).toContain('const canCreateAuth = computed(() => can("startup.auth.create"))');
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
expect(source).toContain('const canDeleteAuth = computed(() => can("startup.auth.delete"))');
expect(source).toContain('v-if="canUpdateAuth"');
expect(source).toContain('v-if="canCreateAuth || canUpdateAuth || canDeleteAuth"');
expect(source).toContain("if (!canCreateAuth.value)");
expect(source).toContain("if (!canUpdateAuth.value)");
expect(source).toContain("if (!canDeleteAuth.value)");
expect(source).not.toContain("trainingRowDraft");
expect(source).not.toContain("isTrainingEditing");
expect(source).not.toContain("saveTraining");
expect(source).not.toContain("cancelTrainingEdit");
expect(source).not.toContain("editMode");
expect(source).not.toContain("saveEdit");
});
it("opens training detail edits in a drawer without navigating to the old edit page", () => {
const source = read("./TrainingDetail.vue");
expect(source).toContain("<TrainingEditorDrawer");
expect(source).toContain("editorVisible.value = true");
expect(source).toContain("const handleEditorSaved = () =>");
expect(source).toContain('const canUpdateAuth = computed(() => can("startup.auth.update"))');
expect(source).toContain('v-if="canUpdateAuth"');
expect(source).toContain("if (!canUpdateAuth.value)");
expect(source).not.toContain("AttachmentList");
expect(source).not.toContain("training_authorization");
expect(source).not.toContain("attachmentRefreshKey");
expect(source).not.toContain("router.push(`/startup/training/${recordId}/edit`)");
});
it("renders kickoff attachments as one aggregated table with attachment type labels", () => {
const source = read("./KickoffDetail.vue");
expect(source).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
expect(source).not.toContain("v-for=\"group in kickoffAttachmentGroups\"");
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffMinutesLabel');
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffSignInLabel');
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffPptLabel');
expect(source).toContain('label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel');
});
it("allows legacy kickoff and training forms to queue attachments before saving", () => {
const kickoffForm = read("./KickoffForm.vue");
const trainingForm = read("./TrainingForm.vue");
expect(kickoffForm).toContain("<AttachmentList");
expect(kickoffForm).toContain('ref="attachmentPanelRef"');
expect(kickoffForm).toContain(":entity-groups=\"kickoffAttachmentGroups\"");
expect(kickoffForm).toContain(':entity-id="meetingId || \'\'"');
expect(kickoffForm).toContain(':mode="\'upload\'"');
expect(kickoffForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(kickoffForm).not.toContain('v-for="group in kickoffAttachmentGroups"');
expect(kickoffForm).not.toContain("StateEmpty");
expect(trainingForm).toContain("<AttachmentList");
expect(trainingForm).toContain('ref="attachmentPanelRef"');
expect(trainingForm).toContain('entity-type="training_authorization"');
expect(trainingForm).toContain(':entity-id="recordId || \'\'"');
expect(trainingForm).toContain(':mode="\'upload\'"');
expect(trainingForm).toContain("await attachmentPanelRef.value?.uploadPending(savedId)");
expect(trainingForm).not.toContain('v-if="isEdit && recordId"');
expect(trainingForm).not.toContain("StateEmpty");
});
it("shows training and authorization dates under positive status in the training table", () => {
const source = read("./KickoffDetail.vue");
expect(source).toContain("trainingStatusLabel(scope.row)");
expect(source).toContain("authorizationStatusLabel(scope.row)");
expect(source).toContain("scope.row.trained && scope.row.trained_date");
expect(source).toContain("scope.row.authorized && scope.row.authorized_date");
expect(source).toContain('class="status-date-small"');
expect(source).not.toContain("scope.row.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
expect(source).not.toContain("scope.row.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no");
});
it("loads training authorizations for the current kickoff site only", () => {
const source = read("./KickoffDetail.vue");
expect(source).toContain("if (!studyId || !siteInfo.name) return;");
expect(source).toContain("listTrainingAuthorizations(studyId, { site_name: siteInfo.name })");
expect(source).not.toContain("listTrainingAuthorizations(studyId);");
});
});
+30 -18
View File
@@ -5,8 +5,7 @@
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
<div class="ctms-section-actions">
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
<el-button v-if="canUpdateAuth" type="primary" :disabled="isInactiveSite" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
</div>
</div>
<el-descriptions :column="2" border>
@@ -20,34 +19,34 @@
<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">
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="training_authorization"
:entity-id="recordId"
:readonly="isReadOnly"
/>
</section>
</div>
<TrainingEditorDrawer
v-model="editorVisible"
:record-id="recordId"
:study-id="studyId"
:sites="sites"
@saved="handleEditorSaved"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const editorVisible = ref(false);
const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
@@ -70,7 +69,9 @@ const siteActiveMap = computed(() => {
});
return map;
});
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const canUpdateAuth = computed(() => can("startup.auth.update"));
const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const isReadOnly = computed(() => !canUpdateAuth.value || isInactiveSite.value);
const loadSites = async () => {
if (!studyId) return;
@@ -88,6 +89,10 @@ const load = async () => {
try {
const { data } = await getTrainingAuthorization(studyId, recordId);
Object.assign(detail, data);
study.setViewContext({
siteName: detail.site_name || TEXT.common.fallback,
pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -96,13 +101,20 @@ const load = async () => {
};
const goEdit = () => {
if (isReadOnly.value) {
if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/startup/training/${recordId}/edit`);
editorVisible.value = true;
};
const handleEditorSaved = () => {
load();
};
const goBack = () => router.push("/startup/meeting-auth");
onMounted(async () => {
await loadSites();
@@ -0,0 +1,566 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="640px"
:close-on-click-modal="true"
:before-close="drawerDirtyGuard.beforeClose"
:show-close="false"
class="startup-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="editor-header">
<div class="editor-title">
{{ editorTitle }}
</div>
</div>
</template>
<StateError v-if="drawerErrorMessage" :description="drawerErrorMessage">
<template #action>
<el-button type="primary" @click="reloadEditingDetail">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="drawerLoading" :rows="6" />
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" class="startup-editor-form">
<div v-if="isCreateMode" class="form-section training-batch-editor">
<div class="section-title action-title">
<span>
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}
</span>
<el-button type="primary" size="small" :disabled="isFormReadOnly" @click="addBatchRow">
{{ TEXT.common.actions.add }}
</el-button>
</div>
<div class="batch-editor">
<div v-for="(row, index) in batchRows" :key="row.tempId" class="batch-entry">
<div class="batch-entry-header">
<span>{{ index + 1 }}</span>
<el-button
v-if="batchRows.length > 1"
link
type="danger"
:disabled="isFormReadOnly"
@click="removeBatchRow(index)"
>
{{ TEXT.common.actions.remove }}
</el-button>
</div>
<div class="compact-row-grid">
<el-form-item class="compact-field compact-field--name" :label="TEXT.common.fields.name" :error="batchErrors[index]?.name">
<el-input v-model="row.name" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item class="compact-field" :label="TEXT.common.labels.role">
<el-input v-model="row.role" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.trained">
<div class="status-inline">
<el-switch v-model="row.trained" :disabled="isFormReadOnly" />
<el-date-picker
v-model="row.trained_date"
:disabled="isFormReadOnly || !row.trained"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-date"
/>
</div>
</el-form-item>
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.authorized">
<div class="status-inline">
<el-switch v-model="row.authorized" :disabled="isFormReadOnly" />
<el-date-picker
v-model="row.authorized_date"
:disabled="isFormReadOnly || !row.authorized"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-date"
/>
</div>
</el-form-item>
<el-form-item class="compact-field compact-field--remark" :label="TEXT.common.fields.remark">
<el-input v-model="row.remark" :disabled="isFormReadOnly" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</div>
</div>
</div>
</div>
<div v-else class="form-section">
<div class="section-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}
</div>
<div class="compact-row-grid compact-row-grid--single">
<el-form-item class="compact-field compact-field--name" :label="TEXT.common.fields.name" prop="name">
<el-input v-model="form.name" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item class="compact-field" :label="TEXT.common.labels.role" prop="role">
<el-input v-model="form.role" :disabled="isFormReadOnly" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.trained">
<div class="status-inline">
<el-switch v-model="form.trained" :disabled="isFormReadOnly" />
<el-date-picker
v-model="form.trained_date"
:disabled="isFormReadOnly || !form.trained"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-date"
/>
</div>
</el-form-item>
<el-form-item class="compact-field compact-field--status" :label="TEXT.common.fields.authorized">
<div class="status-inline">
<el-switch v-model="form.authorized" :disabled="isFormReadOnly" />
<el-date-picker
v-model="form.authorized_date"
:disabled="isFormReadOnly || !form.authorized"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-date"
/>
</div>
</el-form-item>
<el-form-item class="compact-field compact-field--remark" :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" :disabled="isFormReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
</div>
<div v-if="selectedSiteInactive" class="inactive-hint">
<span class="hint-icon">!</span>
<span>中心已停用当前记录不可编辑</span>
</div>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="emit('update:modelValue', false)">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" :disabled="isFormReadOnly || drawerLoading" @click="saveForm">
{{ TEXT.common.actions.save }}
</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { usePermission } from "../../utils/permission";
import { TEXT } from "../../locales";
type TrainingFormModel = {
name: string;
role: string;
site_name: string;
trained: boolean;
authorized: boolean;
trained_date: string;
authorized_date: string;
remark: string;
};
type BatchTrainingFormModel = TrainingFormModel & {
tempId: string;
};
const props = defineProps<{
modelValue: boolean;
recordId?: string;
studyId: string;
sites: any[];
initialSiteName?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
saved: [recordId: string];
}>();
const { can } = usePermission();
const canCreateAuth = computed(() => can("startup.auth.create"));
const canUpdateAuth = computed(() => can("startup.auth.update"));
const isCreateMode = computed(() => !props.recordId);
const canSaveAuth = computed(() => (isCreateMode.value ? canCreateAuth.value : canUpdateAuth.value));
const drawerLoading = ref(false);
const drawerErrorMessage = ref("");
const saving = ref(false);
const formRef = ref<FormInstance>();
const form = reactive<TrainingFormModel>({
name: "",
role: "",
site_name: "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
});
const batchRows = ref<BatchTrainingFormModel[]>([]);
const batchErrors = ref<Record<string, string>[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
props.sites.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const currentSiteName = computed(() => props.initialSiteName || form.site_name || "");
const selectedSiteInactive = computed(() => !!currentSiteName.value && siteActiveMap.value[currentSiteName.value] === false);
const isFormReadOnly = computed(() => !canSaveAuth.value || selectedSiteInactive.value);
const editorTitle = computed(() => (isCreateMode.value ? TEXT.modules.startupMeetingAuth.trainingNewTitle : TEXT.modules.startupMeetingAuth.trainingEditTitle));
const drawerDirtyGuard = useDrawerDirtyGuard(() => ({
form,
batchRows: batchRows.value,
}));
const rules: FormRules<TrainingFormModel> = {
name: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
};
const createEmptyBatchRow = (): BatchTrainingFormModel => ({
tempId: `${Date.now()}-${Math.random()}`,
name: "",
role: "",
site_name: props.initialSiteName || "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
});
const resetForm = () => {
Object.assign(form, {
name: "",
role: "",
site_name: props.initialSiteName || "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
});
batchRows.value = [createEmptyBatchRow()];
batchErrors.value = [];
drawerErrorMessage.value = "";
formRef.value?.clearValidate();
};
const loadEditingDetail = async () => {
if (!props.studyId || !props.recordId) {
drawerDirtyGuard.syncBaseline();
return;
}
drawerLoading.value = true;
drawerErrorMessage.value = "";
try {
const { data } = await getTrainingAuthorization(props.studyId, props.recordId);
Object.assign(form, {
name: data.name || "",
role: data.role || "",
site_name: data.site_name || "",
trained: !!data.trained,
authorized: !!data.authorized,
trained_date: data.trained_date || "",
authorized_date: data.authorized_date || "",
remark: data.remark || "",
});
drawerDirtyGuard.syncBaseline();
} catch (e: any) {
drawerErrorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
drawerLoading.value = false;
}
};
const reloadEditingDetail = () => {
loadEditingDetail();
};
const addBatchRow = () => {
batchRows.value.push(createEmptyBatchRow());
};
const removeBatchRow = (index: number) => {
if (batchRows.value.length <= 1) return;
batchRows.value.splice(index, 1);
batchErrors.value.splice(index, 1);
};
const normalizePayload = (row: TrainingFormModel) => ({
name: row.name.trim(),
role: row.role || null,
site_name: props.initialSiteName || form.site_name || null,
trained: row.trained,
authorized: row.authorized,
trained_date: row.trained && row.trained_date ? row.trained_date : null,
authorized_date: row.authorized && row.authorized_date ? row.authorized_date : null,
remark: row.remark || null,
});
const validateBatchRows = () => {
const errors: Record<string, string>[] = [];
let ok = true;
batchRows.value.forEach((row, index) => {
const entry: Record<string, string> = {};
if (!row.name.trim()) {
entry.name = TEXT.common.messages.required;
ok = false;
}
errors[index] = entry;
});
batchErrors.value = errors;
return ok;
};
const saveForm = async () => {
if (!props.studyId) return;
if (isFormReadOnly.value) {
ElMessage.warning(selectedSiteInactive.value ? "中心已停用" : "权限不足");
return;
}
const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return;
if (isCreateMode.value && !validateBatchRows()) return;
saving.value = true;
try {
let savedId = props.recordId || "";
if (props.recordId) {
await updateTrainingAuthorization(props.studyId, props.recordId, normalizePayload(form));
} else {
for (const row of batchRows.value) {
const { data } = await createTrainingAuthorization(props.studyId, normalizePayload(row));
savedId = data?.id || data?.data?.id || savedId;
}
}
drawerDirtyGuard.syncBaseline();
emit("update:modelValue", false);
emit("saved", savedId);
ElMessage.success(isCreateMode.value ? TEXT.common.messages.createSuccess : TEXT.common.messages.saveSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
watch(
() => props.modelValue,
async (visible) => {
if (!visible) return;
resetForm();
await loadEditingDetail();
},
);
</script>
<style scoped>
:deep(.startup-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 22px 24px 12px;
}
:deep(.startup-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: var(--ctms-text-main);
line-height: 1.3;
}
.startup-editor-form {
padding: 0;
}
.form-section {
padding: 0;
background: transparent;
}
.form-section + .form-section {
margin-top: 20px;
}
.section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
color: #1a3560;
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid #edf2f7;
}
.action-title {
justify-content: space-between;
}
.action-title > span {
display: flex;
align-items: center;
gap: 8px;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.group-dot-basic {
background: #3b82f6;
}
.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;
}
.status-inline {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 0;
}
.status-date {
width: min(156px, calc(100% - 52px));
flex: 1 1 128px;
min-width: 0;
}
.compact-row-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 16px;
align-items: start;
}
.compact-row-grid--single {
margin-bottom: 4px;
}
.compact-field {
margin-bottom: 6px;
}
.compact-field--name {
min-width: 0;
}
.compact-field--status {
min-width: 0;
}
.compact-field--remark {
grid-column: 1 / -1;
}
.batch-editor {
display: flex;
flex-direction: column;
gap: 0;
border-top: 1px solid #edf2f7;
}
.batch-entry {
border: none;
border-radius: 0;
padding: 18px 0 12px;
background: transparent;
border-bottom: 1px solid #edf2f7;
}
.batch-entry:last-child {
border-bottom: none;
}
.batch-entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
color: var(--ctms-text-secondary);
font-size: 13px;
font-weight: 600;
}
:deep(.compact-field .el-form-item__label) {
margin-bottom: 6px;
color: #30415c;
font-size: 13px;
font-weight: 600;
}
:deep(.compact-field .el-input__wrapper),
:deep(.compact-field .el-textarea__inner) {
box-shadow: 0 0 0 1px #dce3ec inset;
}
:deep(.compact-field .el-input__wrapper:hover),
:deep(.compact-field .el-textarea__inner:hover) {
box-shadow: 0 0 0 1px #c8d3e1 inset;
}
:deep(.status-date.is-disabled .el-input__wrapper) {
background: #f6f8fb;
box-shadow: 0 0 0 1px #e2e8f0 inset;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 16px;
}
</style>