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

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
+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>