抽屉化物资与启动授权流程
1、将药物发货新建和编辑从独立页面迁移到抽屉,详情页支持权限控制和停用中心只读状态。 2、补充药物发货状态字段校验,统一 PENDING、IN_TRANSIT、SIGNED、EXCEPTION 状态及演示数据。 3、为物资设备增加详情页和编辑抽屉,复用通用附件上传并按项目权限控制操作入口。 4、将启动会和培训授权编辑迁移到抽屉,详情页按中心过滤培训记录并移除旧独立编辑路由。
This commit is contained in:
@@ -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);");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user