启动与授权-初步优化

This commit is contained in:
Cheng Zhou
2026-01-15 11:25:13 +08:00
parent 4fc5f0ee9b
commit 05c1f9579a
28 changed files with 1082 additions and 300 deletions
+3 -3
View File
@@ -10,7 +10,7 @@
<el-button type="primary" @click="openAdd">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}</el-button>
</div>
<el-table :data="memberRows" v-loading="loading" stripe>
<el-table-column prop="username" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" width="140">
<template #default="scope">
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
@@ -71,7 +71,7 @@
<el-option
v-for="user in availableUsers"
:key="user.id"
:label="user.username"
:label="user.full_name || TEXT.common.fallback"
:value="user.id"
:disabled="!user.is_active"
/>
@@ -168,7 +168,7 @@ const memberRows = computed(() =>
const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
return {
...m,
username: user?.username || m.user_id,
full_name: user?.full_name || user?.username || m.user_id,
effectiveStatus,
};
})
+2 -2
View File
@@ -86,11 +86,11 @@ const rules = reactive<FormRules>({
const craOptions = computed(() => {
const userMap = (props.users || []).reduce<Record<string, string>>((acc, cur: any) => {
if (cur?.id) acc[cur.id] = cur.username || cur.id;
if (cur?.id) acc[cur.id] = cur.full_name || cur.username || cur.id;
return acc;
}, {});
return (props.members || []).map((m: any) => ({
label: userMap[m.user_id] || m.username || m.user_id,
label: userMap[m.user_id] || m.full_name || m.username || m.user_id,
value: m.user_id,
}));
});
+2 -2
View File
@@ -127,10 +127,10 @@ const loadMembers = async () => {
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u) => {
map[u.id] = u.username || u.id;
map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m) => {
if (m.user_id && !map[m.user_id]) map[m.user_id] = m.username || m.user_id;
if (m.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
});
return map;
});
+7 -6
View File
@@ -24,7 +24,7 @@
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="contract_no" :label="TEXT.common.fields.contractNo" min-width="160" />
<el-table-column prop="signed_date" :label="TEXT.common.fields.signedDate" width="140">
@@ -42,11 +42,9 @@
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -99,7 +97,10 @@ const resetFilters = () => {
const goNew = () => router.push("/finance/contracts/new");
const goDetail = (id: string) => router.push(`/finance/contracts/${id}`);
const goEdit = (id: string) => router.push(`/finance/contracts/${id}/edit`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
+7 -6
View File
@@ -29,7 +29,7 @@
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="fee_type" :label="TEXT.common.fields.type" width="120">
<template #default="scope">
@@ -52,11 +52,9 @@
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -109,7 +107,10 @@ const resetFilters = () => {
const goNew = () => router.push("/finance/special/new");
const goDetail = (id: string) => router.push(`/finance/special/${id}`);
const goEdit = (id: string) => router.push(`/finance/special/${id}/edit`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
+7 -6
View File
@@ -24,18 +24,16 @@
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="title" :label="TEXT.common.fields.title" min-width="200" />
<el-table-column prop="level" :label="TEXT.common.fields.level" width="120" />
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -88,7 +86,10 @@ const resetFilters = () => {
const goNew = () => router.push("/knowledge/notes/new");
const goDetail = (id: string) => router.push(`/knowledge/notes/${id}`);
const goEdit = (id: string) => router.push(`/knowledge/notes/${id}/edit`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
+91 -103
View File
@@ -8,133 +8,126 @@
</div>
<el-card>
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.startupMeetingAuth.kickoffTab" name="kickoff">
<div class="tab-actions">
<el-button type="primary" @click="goNewKickoff">{{ TEXT.modules.startupMeetingAuth.kickoffNewTitle }}</el-button>
</div>
<el-table :data="kickoffItems" v-loading="loadingKickoff" style="width: 100%">
<el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
</el-table-column>
<el-table-column prop="attendees" :label="TEXT.common.fields.attendees" min-width="200">
<template #default="scope">
{{ (scope.row.attendees || []).join("、") || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goKickoffDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goKickoffEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingKickoff && kickoffItems.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyKickoff" />
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.startupMeetingAuth.trainingTab" name="training">
<div class="tab-actions">
<el-button type="primary" @click="goNewTraining">{{ TEXT.modules.startupMeetingAuth.trainingNewTitle }}</el-button>
</div>
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%">
<el-table-column prop="name" :label="TEXT.common.fields.name" min-width="140" />
<el-table-column prop="role" :label="TEXT.common.labels.role" min-width="120" />
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="140" />
<el-table-column :label="TEXT.common.fields.trained" width="120">
<template #default="scope">
<el-checkbox v-model="scope.row.trained" @change="toggleTraining(scope.row)" />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.authorized" width="120">
<template #default="scope">
<el-checkbox v-model="scope.row.authorized" @change="toggleTraining(scope.row)" />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goTrainingDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goTrainingEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingTraining && trainingItems.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyTraining" />
</el-tab-pane>
</el-tabs>
<el-table :data="kickoffRows" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onKickoffRowClick">
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="180" />
<el-table-column prop="kickoff_date" :label="TEXT.common.fields.kickoffDate" width="160">
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.startupMeetingAuth.ownerLabel" min-width="160">
<template #default="scope">{{ contactLabel(scope.row) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="140">
<template #default="scope">
<el-tag :type="scope.row.status === 'COMPLETED' ? 'success' : 'info'">
{{ scope.row.status === 'COMPLETED' ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending }}
</el-tag>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && kickoffRows.length === 0" :description="TEXT.modules.startupMeetingAuth.emptyKickoff" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listKickoffs, listTrainingAuthorizations, updateTrainingAuthorization } from "../../api/startup";
import { createKickoff, listKickoffs } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const router = useRouter();
const study = useStudyStore();
const activeTab = ref("kickoff");
const kickoffItems = ref<any[]>([]);
const trainingItems = ref<any[]>([]);
const loadingKickoff = ref(false);
const loadingTraining = ref(false);
const sites = ref<any[]>([]);
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const loading = ref(false);
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u: any) => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
});
return map;
});
const contactLabel = (row: any) => {
if (!row?.contact) return TEXT.common.fallback;
return String(row.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || TEXT.common.fallback)
.join("、") || TEXT.common.fallback;
};
const kickoffRows = computed(() => {
const kickoffMap = kickoffItems.value.reduce<Record<string, any>>((acc, item) => {
if (item.site_id) acc[item.site_id] = item;
return acc;
}, {});
return sites.value.map((site) => {
const meeting = kickoffMap[site.id];
return {
site_id: site.id,
site_name: site.name || TEXT.common.fallback,
contact: site.contact,
kickoff_date: meeting?.kickoff_date || null,
meeting_id: meeting?.id,
status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",
};
});
});
const loadKickoffs = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loadingKickoff.value = true;
loading.value = true;
try {
const { data } = await listKickoffs(studyId);
kickoffItems.value = Array.isArray(data) ? data : data.items || [];
const [sitesResp, kickoffResp, membersResp, usersResp] = await Promise.all([
fetchSites(studyId, { limit: 500 }),
listKickoffs(studyId),
listMembers(studyId, { limit: 500 }),
fetchUsers({ limit: 500 }),
]);
sites.value = Array.isArray(sitesResp.data) ? sitesResp.data : sitesResp.data.items || [];
kickoffItems.value = Array.isArray(kickoffResp.data) ? kickoffResp.data : kickoffResp.data.items || [];
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
users.value = usersResp.data?.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingKickoff.value = false;
loading.value = false;
}
};
const loadTraining = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loadingTraining.value = true;
try {
const { data } = await listTrainingAuthorizations(studyId);
trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingTraining.value = false;
}
};
const toggleTraining = async (row: any) => {
const studyId = study.currentStudy?.id;
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 goNewKickoff = () => router.push("/startup/kickoff/new");
const goKickoffDetail = (id: string) => router.push(`/startup/kickoff/${id}`);
const goKickoffEdit = (id: string) => router.push(`/startup/kickoff/${id}/edit`);
const goNewTraining = () => router.push("/startup/training/new");
const goTrainingDetail = (id: string) => router.push(`/startup/training/${id}`);
const goTrainingEdit = (id: string) => router.push(`/startup/training/${id}/edit`);
const onKickoffRowClick = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId || !row?.site_id) return;
if (row.meeting_id) {
goKickoffDetail(row.meeting_id);
return;
}
try {
const { data } = await createKickoff(studyId, { site_id: row.site_id });
goKickoffDetail(data.id);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.createFailed);
}
};
onMounted(() => {
loadKickoffs();
loadTraining();
});
</script>
@@ -163,9 +156,4 @@ onMounted(() => {
color: var(--ctms-text-secondary);
}
.tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
</style>
+22 -7
View File
@@ -29,7 +29,7 @@
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table :data="items" v-loading="loading" style="width: 100%" class="ctms-table" @row-click="onRowClick">
<el-table-column prop="subject_no" :label="TEXT.common.fields.subjectNo" min-width="140" />
<el-table-column :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</template>
@@ -40,10 +40,9 @@
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate" width="140">
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="160">
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -55,9 +54,9 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { fetchSubjects } from "../../api/subjects";
import { deleteSubject, fetchSubjects } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
@@ -113,7 +112,23 @@ const resetFilters = () => {
const goNew = () => router.push("/subjects/new");
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const goEdit = (id: string) => router.push(`/subjects/${id}/edit`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteSubject(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
onMounted(async () => {
await loadSites();
+6 -10
View File
@@ -28,16 +28,12 @@
</el-descriptions>
</el-card>
<div class="ctms-section-card" v-if="recordId">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<AttachmentList
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
/>
</div>
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
/>
</div>
</template>
@@ -27,16 +27,12 @@
</el-descriptions>
</el-card>
<div class="ctms-section-card" v-if="recordId">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<AttachmentList
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId"
/>
</div>
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId"
/>
</div>
</template>
+382 -16
View File
@@ -6,50 +6,217 @@
<p class="page-subtitle">{{ TEXT.modules.startupMeetingAuth.kickoffDetailSubtitle }}</p>
</div>
<div class="actions">
<el-button type="primary" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button v-if="!editMode" type="primary" :disabled="loading" @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-card v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">{{ displayDate(detail.kickoff_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.attendees">
{{ (detail.attendees || []).join("、") || TEXT.common.fallback }}
<el-descriptions-item :label="TEXT.common.fields.site">
{{ siteInfo.name || TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
{{ ownerLabel }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
<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>
</el-descriptions-item>
</el-descriptions>
</el-card>
<AttachmentList
v-if="meetingId"
:study-id="studyId"
entity-type="startup_kickoff"
:entity-id="meetingId"
/>
<el-card>
<div class="training-header">
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
</div>
<el-table :data="trainingRows" v-loading="loadingTraining" style="width: 100%" class="ctms-table">
<el-table-column prop="name" :label="TEXT.common.fields.name" min-width="140">
<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>
</template>
</el-table-column>
<el-table-column prop="role" :label="TEXT.common.labels.role" min-width="120">
<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>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.trained" width="120">
<template #default="scope">
<el-checkbox
v-if="isTrainingEditing(scope.row)"
v-model="trainingRowDraft.trained"
@click.stop
/>
<el-checkbox v-else v-model="scope.row.trained" @change="toggleTraining(scope.row)" @click.stop />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.authorized" width="120">
<template #default="scope">
<el-checkbox
v-if="isTrainingEditing(scope.row)"
v-model="trainingRowDraft.authorized"
@click.stop
/>
<el-checkbox v-else v-model="scope.row.authorized" @change="toggleTraining(scope.row)" @click.stop />
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<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" @click.stop="startTrainingEdit(scope.row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button link type="danger" size="small" @click.stop="removeTraining(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div
class="training-add-row"
:class="{ disabled: newTrainingActive || savingTraining }"
@click="startTrainingAdd"
>
<span class="training-add-icon">+</span>
</div>
<StateEmpty
v-if="!loadingTraining && trainingItems.length === 0 && !newTrainingActive"
:description="TEXT.modules.startupMeetingAuth.emptyTraining"
/>
</el-card>
<div v-if="meetingId" 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>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getKickoff } from "../../api/startup";
import {
getKickoff,
updateKickoff,
listTrainingAuthorizations,
createTrainingAuthorization,
updateTrainingAuthorization,
deleteTrainingAuthorization,
} from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT, requiredMessage } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const loadingTraining = ref(false);
const saving = ref(false);
const editMode = ref(false);
const meetingId = route.params.meetingId as string;
const studyId = study.currentStudy?.id || "";
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" });
const users = ref<any[]>([]);
const members = ref<any[]>([]);
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 },
];
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> = {};
users.value.forEach((u: any) => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
});
return map;
});
const ownerLabel = computed(() => {
if (!siteInfo.contact) return TEXT.common.fallback;
return String(siteInfo.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
});
const statusLabel = computed(() =>
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
);
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
const load = async () => {
if (!studyId || !meetingId) return;
@@ -57,6 +224,17 @@ 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 || [];
const matched = list.find((site: any) => site.id === data.site_id);
Object.assign(siteInfo, matched || { name: "" });
} else {
Object.assign(siteInfo, { name: "" });
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -64,10 +242,154 @@ const load = async () => {
}
};
const goEdit = () => router.push(`/startup/kickoff/${meetingId}/edit`);
const loadTraining = async () => {
if (!studyId) return;
loadingTraining.value = true;
try {
const { data } = await listTrainingAuthorizations(studyId);
trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingTraining.value = false;
}
};
const toggleTraining = async (row: any) => {
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) return;
newTrainingActive.value = false;
trainingEditingRowId.value = row?.id || null;
resetTrainingDraft(row);
};
const startTrainingAdd = () => {
if (newTrainingActive.value || savingTraining.value) return;
trainingEditingRowId.value = null;
newTrainingActive.value = true;
resetTrainingDraft();
};
const cancelTrainingEdit = () => {
trainingEditingRowId.value = null;
newTrainingActive.value = false;
resetTrainingDraft();
};
const saveTraining = async (row: any) => {
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;
}
};
const removeTraining = async (row: any) => {
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteTrainingAuthorization(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
loadTraining();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const startEdit = () => {
editMode.value = true;
draft.kickoff_date = detail.kickoff_date || "";
};
const cancelEdit = () => {
editMode.value = false;
draft.kickoff_date = detail.kickoff_date || "";
};
const saveEdit = async () => {
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;
}
};
const goBack = () => router.push("/startup/meeting-auth");
onMounted(load);
onMounted(async () => {
if (studyId) {
const [membersResp, usersResp] = await Promise.all([
listMembers(studyId, { limit: 500 }),
fetchUsers({ limit: 500 }),
]).catch(() => [null, null]);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
}
if (usersResp?.data) {
users.value = usersResp.data.items || [];
}
}
await load();
loadTraining();
});
</script>
<style scoped>
@@ -99,4 +421,48 @@ onMounted(load);
display: flex;
gap: 8px;
}
.training-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.training-title {
font-size: 15px;
font-weight: 600;
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;
flex-direction: column;
gap: 16px;
}
</style>
+89 -14
View File
@@ -10,6 +10,12 @@
<el-card>
<el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.site">
<el-input v-model="siteInfo.name" disabled />
</el-form-item>
<el-form-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
<el-input :model-value="ownerLabel" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.kickoffDate">
<el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item>
@@ -28,12 +34,16 @@
</el-form>
</el-card>
<AttachmentList
v-if="isEdit && meetingId"
:study-id="studyId"
entity-type="startup_kickoff"
:entity-id="meetingId"
/>
<div v-if="isEdit && meetingId" 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>
<el-card v-else class="hint-card">
<StateEmpty :description="TEXT.modules.startupMeetingAuth.kickoffUploadHint" />
</el-card>
@@ -46,6 +56,9 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createKickoff, getKickoff, updateKickoff } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
@@ -58,6 +71,37 @@ const saving = ref(false);
const meetingId = computed(() => route.params.meetingId as string | undefined);
const isEdit = computed(() => !!meetingId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const siteInfo = reactive<{ name: string; contact?: string | null }>({ name: "" });
const siteId = ref("");
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u: any) => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
members.value.forEach((m: any) => {
if (m?.user_id && !map[m.user_id]) map[m.user_id] = m.full_name || m.username || m.user_id;
});
return map;
});
const ownerLabel = computed(() => {
if (!siteInfo.contact) return TEXT.common.fallback;
return String(siteInfo.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
});
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 },
];
const form = reactive({
kickoff_date: "",
@@ -72,13 +116,24 @@ const parseAttendees = (value: string) => {
};
const load = async () => {
if (!isEdit.value || !studyId.value || !meetingId.value) return;
if (!studyId.value) return;
try {
const { data } = await getKickoff(studyId.value, meetingId.value);
Object.assign(form, {
kickoff_date: data.kickoff_date || "",
attendees: Array.isArray(data.attendees) ? data.attendees.join("\n") : "",
});
if (isEdit.value && meetingId.value) {
const { data } = await getKickoff(studyId.value, meetingId.value);
Object.assign(form, {
kickoff_date: data.kickoff_date || "",
attendees: Array.isArray(data.attendees) ? data.attendees.join("\n") : "",
});
siteId.value = data.site_id || "";
} else {
siteId.value = (route.query.siteId as string) || "";
}
if (siteId.value) {
const { data: sitesData } = await fetchSites(studyId.value, { limit: 500 });
const list = Array.isArray(sitesData) ? sitesData : sitesData.items || [];
const matched = list.find((site: any) => site.id === siteId.value);
Object.assign(siteInfo, matched || { name: "", contact: "" });
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
@@ -97,7 +152,7 @@ const submit = async () => {
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/startup/kickoff/${meetingId.value}`);
} else {
const { data } = await createKickoff(studyId.value, payload);
const { data } = await createKickoff(studyId.value, { ...payload, site_id: siteId.value });
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/startup/kickoff/${data.id}`);
}
@@ -110,7 +165,21 @@ const submit = async () => {
const goBack = () => router.push("/startup/meeting-auth");
onMounted(load);
onMounted(async () => {
if (studyId.value) {
const [membersResp, usersResp] = await Promise.all([
listMembers(studyId.value, { limit: 500 }),
fetchUsers({ limit: 500 }),
]).catch(() => [null, null]);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
}
if (usersResp?.data) {
users.value = usersResp.data.items || [];
}
}
load();
});
</script>
<style scoped>
@@ -141,4 +210,10 @@ onMounted(load);
.hint-card {
padding: 12px;
}
.attachment-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>