信息架构/菜单框架大重构-20260109

This commit is contained in:
Cheng Zhou
2026-01-09 11:19:04 +08:00
parent ba3cf95b8a
commit 9021c7fe2b
188 changed files with 7632 additions and 11658 deletions
@@ -0,0 +1,12 @@
<template>
<ModulePlaceholder
title="稽查"
subtitle="功能建设中"
list-title="稽查记录列表"
empty-description="稽查模块正在准备基础框架敬请期待"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
</script>
+150
View File
@@ -0,0 +1,150 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">药品运输/流向</h1>
<p class="page-subtitle">登记寄送与回收信息</p>
</div>
<el-button type="primary" @click="goNew">新增运输记录</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item label="分中心">
<el-input v-model="filters.site_name" placeholder="输入分中心名称" clearable />
</el-form-item>
<el-form-item label="运单号">
<el-input v-model="filters.tracking_no" placeholder="输入运单号" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="选择状态" clearable>
<el-option label="待寄出" value="待寄出" />
<el-option label="运输中" value="运输中" />
<el-option label="已签收" value="已签收" />
<el-option label="已回收" value="已回收" />
<el-option label="异常" value="异常" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="direction" label="类型" width="100" />
<el-table-column prop="site_name" label="分中心" min-width="160" />
<el-table-column prop="tracking_no" label="运单号" min-width="160" />
<el-table-column prop="ship_date" label="日期" width="140">
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无运输记录" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const filters = reactive({
site_name: "",
tracking_no: "",
status: "",
});
const load = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
const { data } = await listDrugShipments(studyId, {
site_name: filters.site_name || undefined,
tracking_no: filters.tracking_no || undefined,
status: filters.status || undefined,
});
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.site_name = "";
filters.tracking_no = "";
filters.status = "";
load();
};
const goNew = () => router.push("/drug/shipments/new");
const goDetail = (id: string) => router.push(`/drug/shipments/${id}`);
const goEdit = (id: string) => router.push(`/drug/shipments/${id}/edit`);
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该运输记录?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteDrugShipment(studyId, row.id);
ElMessage.success("已删除");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
onMounted(load);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
}
</style>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">合同费用</h1>
<p class="page-subtitle">维护分中心合同费用与附件</p>
</div>
<el-button type="primary" @click="goNew">新增合同费用</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item label="分中心">
<el-input v-model="filters.site_name" placeholder="输入分中心名称" clearable />
</el-form-item>
<el-form-item label="合同编号">
<el-input v-model="filters.contract_no" placeholder="输入合同编号" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="site_name" label="分中心" min-width="160" />
<el-table-column prop="contract_no" label="合同编号" min-width="160" />
<el-table-column prop="signed_date" label="签署日期" width="140">
<template #default="scope">
{{ displayDate(scope.row.signed_date) }}
</template>
</el-table-column>
<el-table-column label="金额" width="160">
<template #default="scope">
{{ scope.row.amount }} {{ scope.row.currency || "CNY" }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="scope">
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无合同费用记录" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listFinanceContracts, deleteFinanceContract } from "../../api/financeContracts";
import { displayDate, displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const filters = reactive({
site_name: "",
contract_no: "",
});
const load = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
const { data } = await listFinanceContracts(studyId, {
site_name: filters.site_name || undefined,
contract_no: filters.contract_no || undefined,
});
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.site_name = "";
filters.contract_no = "";
load();
};
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 remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该合同费用?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteFinanceContract(studyId, row.id);
ElMessage.success("已删除");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
onMounted(load);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">特殊费用</h1>
<p class="page-subtitle">记录差旅住宿等特殊费用</p>
</div>
<el-button type="primary" @click="goNew">新增特殊费用</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item label="分中心">
<el-input v-model="filters.site_name" placeholder="输入分中心名称" clearable />
</el-form-item>
<el-form-item label="费用类型">
<el-select v-model="filters.fee_type" placeholder="选择类型" clearable>
<el-option label="差旅" value="差旅" />
<el-option label="住宿" value="住宿" />
<el-option label="交通" value="交通" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="site_name" label="分中心" min-width="160" />
<el-table-column prop="fee_type" label="费用类型" width="120" />
<el-table-column prop="occur_date" label="发生日期" width="140">
<template #default="scope">
{{ displayDate(scope.row.occur_date) }}
</template>
</el-table-column>
<el-table-column prop="staff_name" label="人员" min-width="120" />
<el-table-column label="金额" width="140">
<template #default="scope">
{{ scope.row.amount }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="scope">
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无特殊费用记录" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
import { displayDate, displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const filters = reactive({
site_name: "",
fee_type: "",
});
const load = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
const { data } = await listFinanceSpecials(studyId, {
site_name: filters.site_name || undefined,
fee_type: filters.fee_type || undefined,
});
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.site_name = "";
filters.fee_type = "";
load();
};
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 remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该特殊费用?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteFinanceSpecial(studyId, row.id);
ElMessage.success("已删除");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
onMounted(load);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
}
</style>
@@ -0,0 +1,7 @@
<template>
<Faq />
</template>
<script setup lang="ts">
import Faq from "../Faq.vue";
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">注意事项</h1>
<p class="page-subtitle">记录分中心特殊问题与附件</p>
</div>
<el-button type="primary" @click="goNew">新增注意事项</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item label="分中心">
<el-input v-model="filters.site_name" placeholder="输入分中心名称" clearable />
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="filters.keyword" placeholder="标题关键词" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="site_name" label="分中心" min-width="160" />
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="level" label="重要等级" width="120" />
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button>
<el-button link type="danger" size="small" @click="remove(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无注意事项记录" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listKnowledgeNotes, deleteKnowledgeNote } from "../../api/knowledgeNotes";
import { displayDateTime } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const filters = reactive({
site_name: "",
keyword: "",
});
const load = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
const { data } = await listKnowledgeNotes(studyId, {
site_name: filters.site_name || undefined,
keyword: filters.keyword || undefined,
});
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.site_name = "";
filters.keyword = "";
load();
};
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 remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
const ok = await ElMessageBox.confirm("确认删除该注意事项?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteKnowledgeNote(studyId, row.id);
ElMessage.success("已删除");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
onMounted(load);
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
}
</style>
@@ -0,0 +1,12 @@
<template>
<ModulePlaceholder
title="监查"
subtitle="功能建设中"
list-title="监查记录列表"
empty-description="监查功能正在搭建基础能力敬请期待"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
</script>
@@ -0,0 +1,7 @@
<template>
<StudyHome />
</template>
<script setup lang="ts">
import StudyHome from "../StudyHome.vue";
</script>
@@ -0,0 +1,157 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">立项与伦理</h1>
<p class="page-subtitle">记录立项与伦理递交信息</p>
</div>
</div>
<el-card>
<el-tabs v-model="activeTab">
<el-tab-pane label="立项记录" name="feasibility">
<div class="tab-actions">
<el-button type="primary" @click="goNewFeasibility">新增立项记录</el-button>
</div>
<el-table :data="feasibilityItems" v-loading="loadingFeasibility" style="width: 100%">
<el-table-column prop="submit_date" label="递交日期" width="140">
<template #default="scope">{{ displayDate(scope.row.submit_date) }}</template>
</el-table-column>
<el-table-column prop="accept_date" label="受理日期" width="140">
<template #default="scope">{{ displayDate(scope.row.accept_date) }}</template>
</el-table-column>
<el-table-column prop="approved_date" label="同意日期" width="140">
<template #default="scope">{{ displayDate(scope.row.approved_date) }}</template>
</el-table-column>
<el-table-column prop="project_no" label="项目编号" min-width="160" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goFeasibilityDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goFeasibilityEdit(scope.row.id)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingFeasibility && feasibilityItems.length === 0" description="暂无立项记录" />
</el-tab-pane>
<el-tab-pane label="伦理记录" name="ethics">
<div class="tab-actions">
<el-button type="primary" @click="goNewEthics">新增伦理记录</el-button>
</div>
<el-table :data="ethicsItems" v-loading="loadingEthics" style="width: 100%">
<el-table-column prop="submit_date" label="递交日期" width="140">
<template #default="scope">{{ displayDate(scope.row.submit_date) }}</template>
</el-table-column>
<el-table-column prop="accept_date" label="受理日期" width="140">
<template #default="scope">{{ displayDate(scope.row.accept_date) }}</template>
</el-table-column>
<el-table-column prop="meeting_date" label="会议日期" width="140">
<template #default="scope">{{ displayDate(scope.row.meeting_date) }}</template>
</el-table-column>
<el-table-column prop="approved_date" label="批准日期" width="140">
<template #default="scope">{{ displayDate(scope.row.approved_date) }}</template>
</el-table-column>
<el-table-column prop="approval_no" label="批件号" min-width="160" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goEthicsDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goEthicsEdit(scope.row.id)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingEthics && ethicsItems.length === 0" description="暂无伦理记录" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { listFeasibility, listEthics } from "../../api/startup";
import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
const router = useRouter();
const study = useStudyStore();
const activeTab = ref("feasibility");
const feasibilityItems = ref<any[]>([]);
const ethicsItems = ref<any[]>([]);
const loadingFeasibility = ref(false);
const loadingEthics = ref(false);
const loadFeasibility = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loadingFeasibility.value = true;
try {
const { data } = await listFeasibility(studyId);
feasibilityItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loadingFeasibility.value = false;
}
};
const loadEthics = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loadingEthics.value = true;
try {
const { data } = await listEthics(studyId);
ethicsItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loadingEthics.value = false;
}
};
const goNewFeasibility = () => router.push("/startup/feasibility/new");
const goFeasibilityDetail = (id: string) => router.push(`/startup/feasibility/${id}`);
const goFeasibilityEdit = (id: string) => router.push(`/startup/feasibility/${id}/edit`);
const goNewEthics = () => router.push("/startup/ethics/new");
const goEthicsDetail = (id: string) => router.push(`/startup/ethics/${id}`);
const goEthicsEdit = (id: string) => router.push(`/startup/ethics/${id}/edit`);
onMounted(() => {
loadFeasibility();
loadEthics();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
</style>
@@ -0,0 +1,170 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">启动与授权</h1>
<p class="page-subtitle">管理启动会记录与人员培训授权</p>
</div>
</div>
<el-card>
<el-tabs v-model="activeTab">
<el-tab-pane label="启动会记录" name="kickoff">
<div class="tab-actions">
<el-button type="primary" @click="goNewKickoff">新增启动会</el-button>
</div>
<el-table :data="kickoffItems" v-loading="loadingKickoff" style="width: 100%">
<el-table-column prop="kickoff_date" label="启动会日期" width="160">
<template #default="scope">{{ displayDate(scope.row.kickoff_date) }}</template>
</el-table-column>
<el-table-column prop="attendees" label="参训人员" min-width="200">
<template #default="scope">
{{ (scope.row.attendees || []).join("、") || "—" }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goKickoffDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goKickoffEdit(scope.row.id)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingKickoff && kickoffItems.length === 0" description="暂无启动会记录" />
</el-tab-pane>
<el-tab-pane label="培训与授权" name="training">
<div class="tab-actions">
<el-button type="primary" @click="goNewTraining">新增人员</el-button>
</div>
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%">
<el-table-column prop="name" label="姓名" min-width="140" />
<el-table-column prop="role" label="角色" min-width="120" />
<el-table-column prop="site_name" label="分中心" min-width="140" />
<el-table-column label="已培训" width="120">
<template #default="scope">
<el-checkbox v-model="scope.row.trained" @change="toggleTraining(scope.row)" />
</template>
</el-table-column>
<el-table-column label="已授权" width="120">
<template #default="scope">
<el-checkbox v-model="scope.row.authorized" @change="toggleTraining(scope.row)" />
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button link type="primary" size="small" @click="goTrainingDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goTrainingEdit(scope.row.id)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loadingTraining && trainingItems.length === 0" description="暂无培训授权人员" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup lang="ts">
import { 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 { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
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 loadKickoffs = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loadingKickoff.value = true;
try {
const { data } = await listKickoffs(studyId);
kickoffItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loadingKickoff.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 || "加载失败");
} 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("已保存");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
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`);
onMounted(() => {
loadKickoffs();
loadTraining();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">受试者管理</h1>
<p class="page-subtitle">受试者访视与 AE 统一管理</p>
</div>
<el-button type="primary" @click="goNew">新增受试者</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true" :model="filters">
<el-form-item label="编号">
<el-input v-model="filters.subject_no" placeholder="输入受试者编号" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="选择状态" clearable>
<el-option label="筛选" value="SCREENING" />
<el-option label="入组" value="ENROLLED" />
<el-option label="完成" value="COMPLETED" />
<el-option label="退出" value="DROPPED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="subject_no" label="受试者编号" min-width="140" />
<el-table-column label="分中心" min-width="160">
<template #default="scope">{{ siteMap[scope.row.site_id] || "—" }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120" />
<el-table-column prop="enrollment_date" label="入组日期" width="140">
<template #default="scope">{{ displayDate(scope.row.enrollment_date) }}</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">详情</el-button>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="!loading && items.length === 0" description="暂无受试者记录" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { fetchSubjects } from "../../api/subjects";
import { fetchSites } from "../../api/sites";
import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const siteMap = ref<Record<string, string>>({});
const filters = reactive({
subject_no: "",
status: "",
});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
const list = Array.isArray(data) ? data : data.items || [];
siteMap.value = list.reduce((acc: Record<string, string>, site: any) => {
acc[site.id] = site.name;
return acc;
}, {});
} catch {
siteMap.value = {};
}
};
const load = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
const { data } = await fetchSubjects(studyId, {
subject_no: filters.subject_no || undefined,
status_filter: filters.status || undefined,
});
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.subject_no = "";
filters.status = "";
load();
};
const goNew = () => router.push("/subjects/new");
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const goEdit = (id: string) => router.push(`/subjects/${id}/edit`);
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
}
</style>