信息架构/菜单框架大重构-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
+4 -1
View File
@@ -1,4 +1,4 @@
import { apiGet, apiPost, apiPatch } from "./axios";
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchAes = (studyId: string, params?: Record<string, any>) =>
@@ -9,3 +9,6 @@ export const createAe = (studyId: string, payload: Record<string, any>) =>
export const updateAe = (studyId: string, aeId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/aes/${aeId}`, payload);
export const deleteAe = (studyId: string, aeId: string) =>
apiDelete(`/api/v1/studies/${studyId}/aes/${aeId}`);
-14
View File
@@ -1,14 +0,0 @@
import { apiGet, apiPost, apiDelete } from "./axios";
export const fetchComments = (studyId: string, entityType: string, entityId: string) =>
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`);
export const createComment = (
studyId: string,
entityType: string,
entityId: string,
payload: Record<string, any>
) => apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`, payload);
export const deleteComment = (studyId: string, entityType: string, entityId: string, commentId: string) =>
apiDelete(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments/${commentId}`);
-3
View File
@@ -9,6 +9,3 @@ export const fetchFinanceSummary = (studyId: string) =>
export const fetchOverdueAesCount = (studyId: string) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } });
export const fetchOverdueQueriesCount = (studyId: string) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/data-queries/`, { params: { overdue: true, limit: 1 } });
-11
View File
@@ -1,11 +0,0 @@
import { apiGet, apiPost, apiPatch } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchDataQueries = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/data-queries/`, { params });
export const createDataQuery = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/data-queries/`, payload);
export const updateDataQuery = (studyId: string, queryId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/data-queries/${queryId}`, payload);
+16
View File
@@ -0,0 +1,16 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listDrugShipments = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/drug/shipments`, { params });
export const getDrugShipment = (studyId: string, shipmentId: string) =>
apiGet(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`);
export const createDrugShipment = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/drug/shipments`, payload);
export const updateDrugShipment = (studyId: string, shipmentId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`, payload);
export const deleteDrugShipment = (studyId: string, shipmentId: string) =>
apiDelete(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`);
-64
View File
@@ -1,64 +0,0 @@
import { apiGet, apiPatch, apiPost } from "./axios";
import type { AxiosResponse } from "axios";
import type { ApiListResponse } from "../types/api";
export interface FinanceItem {
id: string;
study_id: string;
site_id?: string | null;
subject_id?: string | null;
visit_id?: string | null;
category: string;
title: string;
description?: string | null;
currency: string;
amount: number | string;
occur_date: string;
status: string;
submitted_at?: string | null;
approved_at?: string | null;
rejected_at?: string | null;
paid_at?: string | null;
approver_id?: string | null;
payer_id?: string | null;
reject_reason?: string | null;
created_by: string;
created_at: string;
updated_at?: string | null;
}
export interface FinanceSummary {
total_amount: number | string;
approved_amount: number | string;
paid_amount: number | string;
submitted_amount?: number | string;
count_total: number;
count_paid: number;
}
export const fetchFinanceItems = (
studyId: string,
params?: Record<string, any>
): Promise<AxiosResponse<ApiListResponse<FinanceItem>>> =>
apiGet(`/api/v1/studies/${studyId}/finance/items/`, { params });
export const fetchFinanceItem = (studyId: string, itemId: string) =>
apiGet<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}`);
export const createFinanceItem = (studyId: string, payload: Record<string, any>) =>
apiPost<FinanceItem>(`/api/v1/studies/${studyId}/finance/items`, payload);
export const updateFinanceItem = (studyId: string, itemId: string, payload: Record<string, any>) =>
apiPatch<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}`, payload);
export const changeFinanceStatus = (
studyId: string,
itemId: string,
payload: { status: string; reject_reason?: string }
) => apiPost<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}/status`, payload);
export const fetchFinanceSummary = (studyId: string, params?: Record<string, any>) =>
apiGet<FinanceSummary | ApiListResponse<FinanceSummary>>(
`/api/v1/studies/${studyId}/finance/summary`,
{ params }
);
+16
View File
@@ -0,0 +1,16 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listFinanceContracts = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/finance/contracts`, { params });
export const getFinanceContract = (studyId: string, contractId: string) =>
apiGet(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`);
export const createFinanceContract = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/finance/contracts`, payload);
export const updateFinanceContract = (studyId: string, contractId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`, payload);
export const deleteFinanceContract = (studyId: string, contractId: string) =>
apiDelete(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`);
+16
View File
@@ -0,0 +1,16 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listFinanceSpecials = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/finance/specials`, { params });
export const getFinanceSpecial = (studyId: string, specialId: string) =>
apiGet(`/api/v1/studies/${studyId}/finance/specials/${specialId}`);
export const createFinanceSpecial = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/finance/specials`, payload);
export const updateFinanceSpecial = (studyId: string, specialId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/finance/specials/${specialId}`, payload);
export const deleteFinanceSpecial = (studyId: string, specialId: string) =>
apiDelete(`/api/v1/studies/${studyId}/finance/specials/${specialId}`);
-34
View File
@@ -1,34 +0,0 @@
import { apiGet, apiPatch, apiPost } from "./axios";
import type { AxiosResponse } from "axios";
import type { ApiListResponse } from "../types/api";
export interface ImpBatch {
id: string;
study_id: string;
product_id: string;
batch_no: string;
expiry_date?: string | null;
manufacture_date?: string | null;
status: string;
created_at: string;
updated_at: string;
}
export const fetchImpBatches = (
studyId: string,
params?: Record<string, any>
): Promise<AxiosResponse<ApiListResponse<ImpBatch>>> =>
apiGet(`/api/v1/studies/${studyId}/imp/batches/`, { params });
export const createImpBatch = (
studyId: string,
payload: Record<string, any>
): Promise<AxiosResponse<ImpBatch>> =>
apiPost(`/api/v1/studies/${studyId}/imp/batches/`, payload);
export const updateImpBatch = (
studyId: string,
batchId: string,
payload: Record<string, any>
): Promise<AxiosResponse<ImpBatch>> =>
apiPatch(`/api/v1/studies/${studyId}/imp/batches/${batchId}`, payload);
-16
View File
@@ -1,16 +0,0 @@
import { apiGet } from "./axios";
import type { AxiosResponse } from "axios";
import type { ApiListResponse } from "../types/api";
export interface ImpInventoryRow {
site_id: string;
batch_id: string;
quantity_on_hand: number;
updated_at: string;
}
export const fetchImpInventory = (
studyId: string,
params?: Record<string, any>
): Promise<AxiosResponse<ApiListResponse<ImpInventoryRow>>> =>
apiGet(`/api/v1/studies/${studyId}/imp/inventory/`, { params });
-34
View File
@@ -1,34 +0,0 @@
import { apiGet, apiPatch, apiPost } from "./axios";
import type { AxiosResponse } from "axios";
import type { ApiListResponse } from "../types/api";
export interface ImpProduct {
id: string;
study_id: string;
name: string;
form?: string | null;
strength?: string | null;
unit: string;
description?: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export const fetchImpProducts = (
studyId: string
): Promise<AxiosResponse<ApiListResponse<ImpProduct>>> =>
apiGet(`/api/v1/studies/${studyId}/imp/products/`);
export const createImpProduct = (
studyId: string,
payload: Record<string, any>
): Promise<AxiosResponse<ImpProduct>> =>
apiPost(`/api/v1/studies/${studyId}/imp/products/`, payload);
export const updateImpProduct = (
studyId: string,
productId: string,
payload: Record<string, any>
): Promise<AxiosResponse<ImpProduct>> =>
apiPatch(`/api/v1/studies/${studyId}/imp/products/${productId}`, payload);
-30
View File
@@ -1,30 +0,0 @@
import { apiGet, apiPost } from "./axios";
import type { AxiosResponse } from "axios";
import type { ApiListResponse } from "../types/api";
export interface ImpTransaction {
id: string;
study_id: string;
site_id: string;
batch_id: string;
subject_id?: string | null;
tx_type: string;
quantity: number;
tx_date: string;
reference?: string | null;
notes?: string | null;
created_by: string;
created_at: string;
}
export const fetchImpTransactions = (
studyId: string,
params?: Record<string, any>
): Promise<AxiosResponse<ApiListResponse<ImpTransaction>>> =>
apiGet(`/api/v1/studies/${studyId}/imp/transactions/`, { params });
export const createImpTransaction = (
studyId: string,
payload: Record<string, any>
): Promise<AxiosResponse<ImpTransaction>> =>
apiPost(`/api/v1/studies/${studyId}/imp/transactions/`, payload);
-11
View File
@@ -1,11 +0,0 @@
import { apiGet, apiPost, apiPatch } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchIssues = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/issues/`, { params });
export const createIssue = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/issues/`, payload);
export const updateIssue = (studyId: string, issueId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/issues/${issueId}`, payload);
+16
View File
@@ -0,0 +1,16 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listKnowledgeNotes = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/knowledge/notes`, { params });
export const getKnowledgeNote = (studyId: string, noteId: string) =>
apiGet(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`);
export const createKnowledgeNote = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/knowledge/notes`, payload);
export const updateKnowledgeNote = (studyId: string, noteId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`, payload);
export const deleteKnowledgeNote = (studyId: string, noteId: string) =>
apiDelete(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`);
-14
View File
@@ -1,14 +0,0 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchMilestones = (studyId: string) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/milestones/`);
export const createMilestone = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/milestones/`, payload);
export const updateMilestone = (studyId: string, milestoneId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/milestones/${milestoneId}`, payload);
export const deleteMilestone = (studyId: string, milestoneId: string) =>
apiDelete(`/api/v1/studies/${studyId}/milestones/${milestoneId}`);
+61
View File
@@ -0,0 +1,61 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listFeasibility = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/startup/feasibility`, { params });
export const getFeasibility = (studyId: string, id: string) =>
apiGet(`/api/v1/studies/${studyId}/startup/feasibility/${id}`);
export const createFeasibility = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/startup/feasibility`, payload);
export const updateFeasibility = (studyId: string, id: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/startup/feasibility/${id}`, payload);
export const deleteFeasibility = (studyId: string, id: string) =>
apiDelete(`/api/v1/studies/${studyId}/startup/feasibility/${id}`);
export const listEthics = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/startup/ethics`, { params });
export const getEthics = (studyId: string, id: string) =>
apiGet(`/api/v1/studies/${studyId}/startup/ethics/${id}`);
export const createEthics = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/startup/ethics`, payload);
export const updateEthics = (studyId: string, id: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/startup/ethics/${id}`, payload);
export const deleteEthics = (studyId: string, id: string) =>
apiDelete(`/api/v1/studies/${studyId}/startup/ethics/${id}`);
export const listKickoffs = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/startup/kickoff`, { params });
export const getKickoff = (studyId: string, id: string) =>
apiGet(`/api/v1/studies/${studyId}/startup/kickoff/${id}`);
export const createKickoff = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/startup/kickoff`, payload);
export const updateKickoff = (studyId: string, id: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/startup/kickoff/${id}`, payload);
export const deleteKickoff = (studyId: string, id: string) =>
apiDelete(`/api/v1/studies/${studyId}/startup/kickoff/${id}`);
export const listTrainingAuthorizations = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/startup/training-authorizations`, { params });
export const getTrainingAuthorization = (studyId: string, id: string) =>
apiGet(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`);
export const createTrainingAuthorization = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/startup/training-authorizations`, payload);
export const updateTrainingAuthorization = (studyId: string, id: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`, payload);
export const deleteTrainingAuthorization = (studyId: string, id: string) =>
apiDelete(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`);
+20
View File
@@ -0,0 +1,20 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listSubjectHistories = (studyId: string, subjectId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories`, { params });
export const getSubjectHistory = (studyId: string, subjectId: string, historyId: string) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`);
export const createSubjectHistory = (studyId: string, subjectId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories`, payload);
export const updateSubjectHistory = (
studyId: string,
subjectId: string,
historyId: string,
payload: Record<string, any>
) => apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`, payload);
export const deleteSubjectHistory = (studyId: string, subjectId: string, historyId: string) =>
apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`);
+7 -1
View File
@@ -1,4 +1,4 @@
import { apiGet, apiPost, apiPatch } from "./axios";
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchSubjects = (studyId: string, params?: Record<string, any>) =>
@@ -7,5 +7,11 @@ export const fetchSubjects = (studyId: string, params?: Record<string, any>) =>
export const createSubject = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/subjects/`, payload);
export const getSubject = (studyId: string, subjectId: string) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}`);
export const updateSubject = (studyId: string, subjectId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}`, payload);
export const deleteSubject = (studyId: string, subjectId: string) =>
apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}`);
-10
View File
@@ -1,10 +0,0 @@
import { apiGet, apiPost } from "./axios";
export const fetchVerifications = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/verifications/`, { params });
export const upsertVerification = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/verifications/`, payload);
export const fetchVerification = (studyId: string, subjectId: string, level: string) =>
apiGet(`/api/v1/studies/${studyId}/verifications/subjects/${subjectId}/${level}`);
+7 -1
View File
@@ -1,7 +1,13 @@
import { apiGet, apiPatch } from "./axios";
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const fetchVisits = (studyId: string, subjectId: string) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`);
export const createVisit = (studyId: string, subjectId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`, payload);
export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload);
export const deleteVisit = (studyId: string, subjectId: string, visitId: string) =>
apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`);
-191
View File
@@ -1,191 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑 AE' : '新增 AE'" v-model="visible" width="600px" @close="onClose" class="ctms-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
<el-form-item label="受试者" prop="subject_id">
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可留空" />
</el-form-item>
<el-form-item label="事件描述" prop="term">
<el-input v-model="form.term" />
</el-form-item>
<el-form-item label="发生日期" prop="onset_date">
<el-date-picker
v-model="form.onset_date"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableFutureDate"
/>
</el-form-item>
<el-form-item label="SAE" prop="seriousness">
<el-select v-model="form.seriousness" placeholder="请选择">
<el-option label="是" value="SERIOUS" />
<el-option label="否" value="NON_SERIOUS" />
</el-select>
</el-form-item>
<el-form-item label="严重程度" prop="severity">
<el-select v-model="form.severity" placeholder="请选择">
<el-option v-for="s in severities" :key="s" :label="severityLabel(s)" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
<el-form-item label="转归结局" prop="outcome">
<el-select v-model="form.outcome" placeholder="可留空" clearable>
<el-option v-for="o in outcomes" :key="o" :label="o" :value="o" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="footer-right">
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createAe, updateAe } from "../api/aes";
import { useStudyStore } from "../store/study";
import SubjectSelect from "./selectors/SubjectSelect.vue";
const props = defineProps<{
modelValue: boolean;
ae?: Record<string, any>;
subjects?: any[];
}>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const severities = ["G1", "G2", "G3", "G4", "G5"];
const outcomes = ["痊愈", "好转", "持续", "恶化", "死亡", "其他"];
const severityLabel = (v: string) =>
({
G1: "I级",
G2: "II级",
G3: "III级",
G4: "IV级",
G5: "V级",
}[v] || v);
const form = reactive({
subject_id: "",
term: "",
onset_date: "",
seriousness: "NON_SERIOUS",
severity: "G1",
description: "",
outcome: "",
});
const rules: FormRules = {
term: [{ required: true, message: "请输入事件描述", trigger: "blur" }],
onset_date: [{ required: true, message: "请选择发生日期", trigger: "change" }],
seriousness: [{ required: true, message: "请选择严重性", trigger: "change" }],
severity: [{ required: true, message: "请选择严重程度", trigger: "change" }],
};
const isEdit = computed(() => !!props.ae);
watch(
() => props.ae,
(val) => {
if (val) {
Object.assign(form, {
subject_id: val.subject_id || "",
term: val.term || "",
onset_date: val.onset_date || "",
seriousness: val.seriousness || "NON_SERIOUS",
severity: val.severity || "G1",
description: val.description || "",
outcome: val.outcome || "",
});
} else {
Object.assign(form, {
subject_id: "",
term: "",
onset_date: "",
seriousness: "NON_SERIOUS",
severity: "G1",
description: "",
outcome: "",
});
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const disableFutureDate = (time: Date) => time.getTime() > Date.now();
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (form.onset_date && new Date(form.onset_date) > new Date()) {
ElMessage.warning("发生日期不得晚于当前日期");
return;
}
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload: Record<string, any> = {};
Object.entries(form).forEach(([k, v]) => {
if (v !== "" && v !== null && v !== undefined) {
payload[k] = v;
}
});
if (form.outcome === "" || form.outcome === null || form.outcome === undefined) {
payload.outcome = null;
}
if (isEdit.value && props.ae) {
await updateAe(study.currentStudy.id, props.ae.id, payload);
} else {
await createAe(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 6px;
}
:deep(.el-dialog__body) {
padding-top: 12px;
}
:deep(.el-dialog__footer) {
display: flex;
justify-content: flex-end;
align-items: center;
}
</style>
@@ -1,84 +0,0 @@
<template>
<el-card class="attachment-card">
<template #header>
<div class="header">
<span>附件</span>
<el-upload
v-if="canUpload"
:http-request="onUpload"
:show-file-list="false"
:auto-upload="false"
:limit="1"
>
<el-button size="small" type="primary">上传</el-button>
</el-upload>
</div>
</template>
<el-table :data="attachments" v-loading="loading" style="width: 100%">
<el-table-column prop="filename" label="文件名" />
<el-table-column prop="uploaded_by" label="上传人" width="160" />
<el-table-column prop="uploaded_at" label="上传时间" width="180" />
<el-table-column label="操作" width="120">
<template #default="scope">
<el-link type="primary" :href="scope.row.file_path" target="_blank">下载</el-link>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchAttachments, uploadAttachment } from "../api/attachments";
import { useStudyStore } from "../store/study";
interface Props {
studyId: string;
entityType: string;
entityId: string;
canUpload?: boolean;
}
const props = defineProps<Props>();
const study = useStudyStore();
const attachments = ref<any[]>([]);
const loading = ref(false);
const load = async () => {
if (!props.studyId) return;
loading.value = true;
try {
const { data } = await fetchAttachments(props.studyId, props.entityType, props.entityId);
attachments.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "附件加载失败");
} finally {
loading.value = false;
}
};
const onUpload = async (options: any) => {
if (!study.currentStudy) return;
loading.value = true;
try {
await uploadAttachment(study.currentStudy.id, props.entityType, props.entityId, options.file as File);
ElMessage.success("上传成功");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "上传失败");
} finally {
loading.value = false;
}
};
onMounted(() => load());
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
-204
View File
@@ -1,204 +0,0 @@
<template>
<el-card class="comment-card">
<template #header>
<div class="header">
<span>评论</span>
<el-button v-if="canComment" type="primary" size="small" @click="showInput = !showInput">新增</el-button>
</div>
</template>
<ThreadComposer
v-if="showInput"
v-model="newComment"
v-model:file-list="fileList"
:submitting="loading || uploading"
:quote-item="quoteComment"
:member-map="memberMap"
:user-map="userMap"
:allow-attachments="true"
show-cancel
@submit="submit"
@cancel="showInput = false"
@clear-quote="clearQuote"
/>
<ThreadList
:items="comments"
:attachments-map="attachmentsMap"
:member-map="memberMap"
:user-map="userMap"
:can-quote="canComment"
:can-delete="canDelete"
@quote="setQuote"
@delete="remove"
/>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import type { UploadUserFile } from "element-plus";
import { fetchComments, createComment, deleteComment } from "../api/comments";
import { fetchAttachments, uploadAttachment } from "../api/attachments";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import { listMembers } from "../api/members";
import { getMemberDisplayName } from "../utils/display";
import ThreadComposer from "./ThreadComposer.vue";
import ThreadList from "./ThreadList.vue";
interface Props {
studyId: string;
entityType: string;
entityId: string;
canComment?: boolean;
}
const props = defineProps<Props>();
const comments = ref<any[]>([]);
const loading = ref(false);
const newComment = ref("");
const showInput = ref(false);
const study = useStudyStore();
const auth = useAuthStore();
const members = ref<any[]>([]);
const quoteComment = ref<any | null>(null);
const fileList = ref<UploadUserFile[]>([]);
const attachmentsMap = ref<Record<string, any[]>>({});
const uploading = ref(false);
const load = async () => {
if (!props.studyId) return;
loading.value = true;
try {
const { data } = await fetchComments(props.studyId, props.entityType, props.entityId);
comments.value = data.items || data || [];
await loadAttachments();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "评论加载失败");
} finally {
loading.value = false;
}
};
const loadMembers = async () => {
if (!props.studyId) return;
try {
const { data } = await listMembers(props.studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
return acc;
}, {})
);
const userMap = computed(() => {
const map: Record<string, string> = {};
if (auth.user?.id) {
map[auth.user.id] = auth.user.display_name || auth.user.username || auth.user.email || auth.user.id;
}
return map;
});
const submit = async () => {
if (!newComment.value.trim()) {
ElMessage.warning("请输入评论");
return;
}
if (!study.currentStudy) return;
loading.value = true;
try {
const { data } = await createComment(study.currentStudy.id, props.entityType, props.entityId, {
content: newComment.value,
quote_comment_id: quoteComment.value?.id || null,
});
const created = data as any;
if (fileList.value.length && created?.id) {
uploading.value = true;
try {
for (const file of fileList.value) {
if (!file.raw) continue;
await uploadAttachment(study.currentStudy.id, "comments", created.id, file.raw as File);
}
} finally {
uploading.value = false;
}
}
ElMessage.success("提交成功");
newComment.value = "";
quoteComment.value = null;
fileList.value = [];
showInput.value = false;
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
loading.value = false;
}
};
const setQuote = (comment: any) => {
quoteComment.value = comment;
showInput.value = true;
};
const clearQuote = () => {
quoteComment.value = null;
};
const canDelete = (comment: any) => auth.user?.role === "ADMIN" || comment?.created_by === auth.user?.id;
const remove = async (comment: any) => {
if (!study.currentStudy || !comment?.id) return;
const ok = await ElMessageBox.confirm("确认删除该评论?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteComment(study.currentStudy.id, props.entityType, props.entityId, comment.id);
ElMessage.success("评论已删除");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
const loadAttachments = async () => {
if (!props.studyId || comments.value.length === 0) {
attachmentsMap.value = {};
return;
}
const entries = await Promise.all(
comments.value.map(async (c) => {
try {
const { data } = await fetchAttachments(props.studyId, "comments", c.id);
const items = (data as any).items || data || [];
return [c.id, items] as const;
} catch {
return [c.id, []] as const;
}
})
);
const next: Record<string, any[]> = {};
entries.forEach(([id, items]) => {
next[id] = items;
});
attachmentsMap.value = next;
};
onMounted(() => {
loadMembers();
load();
});
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
-164
View File
@@ -1,164 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑数据问题' : '新建数据问题'" v-model="visible" width="600px" @close="onClose" class="ctms-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="受试者" prop="subject_id">
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可选" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择">
<el-option label="缺失(MISSING)" value="MISSING" />
<el-option label="不一致(INCONSISTENT)" value="INCONSISTENT" />
<el-option label="超范围(OUT_OF_RANGE)" value="OUT_OF_RANGE" />
<el-option label="其他(OTHER)" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-select v-model="form.priority" placeholder="请选择">
<el-option label="低" value="LOW" />
<el-option label="中" value="MEDIUM" />
<el-option label="高" value="HIGH" />
</el-select>
</el-form-item>
<el-form-item label="指派给" prop="assigned_to">
<UserSelect v-model="form.assigned_to" placeholder="可选" />
</el-form-item>
<el-form-item label="截止日期" prop="due_date">
<el-date-picker v-model="form.due_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<div class="footer-right">
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createDataQuery, updateDataQuery } from "../api/dataQueries";
import { useStudyStore } from "../store/study";
import SubjectSelect from "./selectors/SubjectSelect.vue";
import UserSelect from "./selectors/UserSelect.vue";
const props = defineProps<{
modelValue: boolean;
query?: Record<string, any>;
}>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const isEdit = computed(() => !!props.query);
const form = reactive({
title: "",
subject_id: "",
category: "MISSING",
priority: "MEDIUM",
assigned_to: "",
due_date: "",
description: "",
});
const rules: FormRules = {
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
category: [{ required: true, message: "请选择类别", trigger: "change" }],
priority: [{ required: true, message: "请选择优先级", trigger: "change" }],
};
watch(
() => props.query,
(val) => {
if (val) {
Object.assign(form, {
title: val.title || "",
subject_id: val.subject_id || "",
category: val.category || "MISSING",
priority: val.priority || "MEDIUM",
assigned_to: val.assigned_to || "",
due_date: val.due_date || "",
description: val.description || "",
});
} else {
Object.assign(form, {
title: "",
subject_id: "",
category: "MISSING",
priority: "MEDIUM",
assigned_to: "",
due_date: "",
description: "",
});
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload: Record<string, any> = {};
Object.entries(form).forEach(([k, v]) => {
if (v !== "" && v !== null && v !== undefined) payload[k] = v;
});
if (isEdit.value && props.query) {
await updateDataQuery(study.currentStudy.id, props.query.id, payload);
} else {
await createDataQuery(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 6px;
}
:deep(.el-dialog__body) {
padding-top: 12px;
}
:deep(.el-dialog__footer) {
display: flex;
justify-content: flex-end;
align-items: center;
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑 FAQ' : '新建 FAQ'" width="640px" @close="close">
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑咨询' : '新建咨询'" width="640px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="请选择分类">
+3 -3
View File
@@ -74,7 +74,7 @@ const categoryMap = computed(() =>
);
const view = (row: FaqItem) => {
router.push(`/study/faq/${row.id}`);
router.push(`/knowledge/medical-consult/${row.id}`);
};
const onRowClick = (row: FaqItem, column: any, event: MouseEvent) => {
@@ -99,7 +99,7 @@ const canDelete = (row: FaqItem) => props.canEdit || row.created_by === auth.use
const toggle = async (row: FaqItem) => {
const target = !row.is_active;
const ok = await ElMessageBox.confirm(`确认${target ? "启用" : "停用"} FAQ?`, "提示").catch(() => null);
const ok = await ElMessageBox.confirm(`确认${target ? "启用" : "停用"}咨询?`, "提示").catch(() => null);
if (!ok) return;
try {
await updateFaqItem(row.id, { is_active: target });
@@ -111,7 +111,7 @@ const toggle = async (row: FaqItem) => {
};
const remove = async (row: FaqItem) => {
const ok = await ElMessageBox.confirm(`确认删除 FAQ${row.question}」?`, "提示").catch(() => null);
const ok = await ElMessageBox.confirm(`确认删除咨询${row.question}」?`, "提示").catch(() => null);
if (!ok) return;
try {
await deleteFaqItem(row.id);
-147
View File
@@ -1,147 +0,0 @@
<template>
<el-dialog :model-value="modelValue" title="费用" width="520px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="金额" prop="amount">
<el-input-number v-model="form.amount" :min="0.01" :step="100" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="币种">
<el-input v-model="form.currency" />
</el-form-item>
<el-form-item label="发生日期" prop="occur_date">
<el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="中心">
<SiteSelect v-model="form.site_id" :study-id="study.currentStudy?.id || null" placeholder="可选" />
</el-form-item>
<el-form-item label="受试者">
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createFinanceItem, updateFinanceItem } from "../api/finance";
import SiteSelect from "./selectors/SiteSelect.vue";
import SubjectSelect from "./selectors/SubjectSelect.vue";
const props = defineProps<{ modelValue: boolean; item?: any }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const categories = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"];
const categoryLabel = (v: string) =>
({
SITE_FEE: "中心费用",
SUBJECT_STIPEND: "受试者补贴",
TRAVEL: "差旅",
OTHER: "其他",
}[v] || v);
const form = reactive({
category: "SITE_FEE",
title: "",
description: "",
amount: 0,
currency: "CNY",
occur_date: "",
site_id: "",
subject_id: "",
});
const rules: FormRules = {
category: [{ required: true, message: "请选择类别", trigger: "change" }],
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
occur_date: [{ required: true, message: "请选择日期", trigger: "change" }],
};
const isEdit = computed(() => !!props.item);
const reset = () => {
form.category = "SITE_FEE";
form.title = "";
form.description = "";
form.amount = 0;
form.currency = "CNY";
form.occur_date = "";
form.site_id = "";
form.subject_id = "";
};
watch(
() => props.item,
(val) => {
if (val) {
form.category = val.category;
form.title = val.title;
form.description = val.description || "";
form.amount = Number(val.amount) || 0;
form.currency = val.currency || "CNY";
form.occur_date = val.occur_date;
form.site_id = val.site_id || "";
form.subject_id = val.subject_id || "";
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
if (form.amount <= 0) {
ElMessageBox.alert("金额必须大于 0");
return;
}
submitting.value = true;
try {
const payload = {
category: form.category,
title: form.title,
description: form.description || null,
amount: Number(form.amount),
currency: form.currency,
occur_date: form.occur_date,
site_id: form.site_id ? form.site_id : null,
subject_id: form.subject_id ? form.subject_id : null,
};
if (isEdit.value && props.item) {
await updateFinanceItem(study.currentStudy.id, props.item.id, payload);
} else {
await createFinanceItem(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
};
</script>
@@ -1,155 +0,0 @@
<template>
<div class="actions">
<template v-for="action in availableActions" :key="action.key">
<PermissionAction :action="permissionMap[action.key]">
<el-button
size="small"
:type="buttonType(action)"
:loading="loading"
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</PermissionAction>
</template>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, ref } from "vue";
import { changeFinanceStatus } from "../api/finance";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "./PermissionAction.vue";
import { financeMachine, getAvailableActions } from "../state-machine";
import type { ActionConfig } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const props = defineProps<{ item: any }>();
const emit = defineEmits(["success"]);
const study = useStudyStore();
const auth = useAuthStore();
const loading = ref(false);
const permissionMap: Record<string, string> = {
submit: "finance.create",
approve: "finance.approve",
reject: "finance.approve",
pay: "finance.pay",
};
const availableActions = computed(() => getAvailableActions(financeMachine, props.item?.status));
const buttonType = (action: ActionConfig) => {
if (action.danger) return "danger";
if (action.key === "approve") return "success";
if (action.key === "pay") return "warning";
return "primary";
};
const change = async (status: string, reject_reason?: string) => {
if (!study.currentStudy) return false;
loading.value = true;
try {
await changeFinanceStatus(study.currentStudy.id, props.item.id, { status, reject_reason });
ElMessage.success("状态已更新");
emit("success");
return true;
} catch (e: any) {
const msg = e?.response?.data?.detail || e?.response?.data?.message || "更新失败";
ElMessage.error(msg);
if (e?.response?.status === 403) {
logAudit("UNAUTHORIZED_ACTION_ATTEMPT", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: msg,
severity: "violation",
});
} else {
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: msg,
severity: "warning",
});
}
return false;
} finally {
loading.value = false;
}
};
const onAction = async (action: ActionConfig) => {
if (!props.item) return;
const prevStatus = props.item.status;
if (prevStatus === action.to) {
ElMessage.warning("当前状态不允许该操作");
logAudit("INVALID_STATE_TRANSITION_ATTEMPT", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: "warning",
});
return;
}
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap[action.key],
stateMachine: financeMachine,
currentState: props.item.status,
actionKey: action.key,
target: { id: props.item.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示", {
type: action.danger ? "warning" : "info",
}).catch(() => null);
if (!ok) return;
}
if (action.key === "reject") {
const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回审批", {
confirmButtonText: "确定",
cancelButtonText: "取消",
}).catch(() => null);
if (!reason || !reason.value) return;
const ok = await change(action.to, reason.value);
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: ok ? "normal" : "warning",
});
return;
}
const ok = await change(action.to);
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: ok ? "normal" : "warning",
});
};
</script>
<style scoped>
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
@@ -1,65 +0,0 @@
<template>
<el-card>
<div class="summary">
<div class="item">
<div class="label">总金额全部</div>
<div class="value">{{ format(summary?.total_amount) }}</div>
</div>
<div class="item">
<div class="label">待审批金额</div>
<div class="value">{{ format(summary?.submitted_amount) }}</div>
</div>
<div class="item">
<div class="label">已审批金额</div>
<div class="value">{{ format(summary?.approved_amount) }}</div>
</div>
<div class="item">
<div class="label">已支付金额</div>
<div class="value">{{ format(summary?.paid_amount) }}</div>
</div>
<div class="item">
<div class="label">条目数</div>
<div class="value">{{ summary?.count_total ?? 0 }}</div>
</div>
<div class="item">
<div class="label">已支付条目</div>
<div class="value">{{ summary?.count_paid ?? 0 }}</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import type { FinanceSummary } from "../api/finance";
defineProps<{
summary?: FinanceSummary;
}>();
const format = (num?: number | string) => {
const n = Number(num);
return Number.isFinite(n) ? n.toFixed(2) : "0.00";
};
</script>
<style scoped>
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.item {
background: #f5f7fa;
border-radius: 6px;
padding: 12px;
}
.label {
color: #606266;
font-size: 12px;
}
.value {
font-weight: 600;
margin-top: 4px;
font-size: 16px;
}
</style>
-125
View File
@@ -1,125 +0,0 @@
<template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑批次' : '新增批次'" width="520px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="产品" prop="product_id">
<el-select v-model="form.product_id" placeholder="请选择">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="批号" prop="batch_no">
<el-input v-model="form.batch_no" />
</el-form-item>
<el-form-item label="失效日期">
<el-date-picker v-model="form.expiry_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="生产日期">
<el-date-picker v-model="form.manufacture_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" placeholder="请选择状态">
<el-option
v-for="s in statusOptions"
:key="s"
:label="`${statusLabel(s)}(${s})`"
:value="s"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createImpBatch, updateImpBatch } from "../api/impBatches";
const props = defineProps<{ modelValue: boolean; batch?: any; products: any[] }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const statusOptions = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"];
const form = reactive({
product_id: "",
batch_no: "",
expiry_date: "",
manufacture_date: "",
status: "ACTIVE",
});
const rules: FormRules = {
product_id: [{ required: true, message: "请选择产品", trigger: "change" }],
batch_no: [{ required: true, message: "请输入批号", trigger: "blur" }],
};
const isEdit = computed(() => !!props.batch);
const reset = () => {
form.product_id = "";
form.batch_no = "";
form.expiry_date = "";
form.manufacture_date = "";
form.status = "ACTIVE";
};
watch(
() => props.batch,
(val) => {
if (val) {
form.product_id = val.product_id;
form.batch_no = val.batch_no;
form.expiry_date = val.expiry_date || "";
form.manufacture_date = val.manufacture_date || "";
form.status = val.status || "ACTIVE";
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
if (isEdit.value && props.batch) {
const payload = {
status: form.status,
expiry_date: form.expiry_date || null,
manufacture_date: form.manufacture_date || null,
};
await updateImpBatch(study.currentStudy.id, props.batch.id, payload);
} else {
const payload = {
product_id: form.product_id,
batch_no: form.batch_no,
expiry_date: form.expiry_date || null,
manufacture_date: form.manufacture_date || null,
};
await createImpBatch(study.currentStudy.id, payload);
}
ElMessage.success("保存成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
const statusLabel = (v: string) =>
({ ACTIVE: "在库", QUARANTINED: "隔离", EXPIRED: "过期", CLOSED: "关闭" }[v] || v);
</script>
-106
View File
@@ -1,106 +0,0 @@
<template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑产品' : '新增产品'" width="520px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="剂型">
<el-input v-model="form.form" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="form.strength" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" rows="2" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createImpProduct, updateImpProduct } from "../api/impProducts";
const props = defineProps<{ modelValue: boolean; product?: any }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
name: "",
form: "",
strength: "",
unit: "",
description: "",
is_active: true,
});
const rules: FormRules = {
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
unit: [{ required: true, message: "请输入单位", trigger: "blur" }],
};
const isEdit = computed(() => !!props.product);
const reset = () => {
form.name = "";
form.form = "";
form.strength = "";
form.unit = "";
form.description = "";
form.is_active = true;
};
watch(
() => props.product,
(val) => {
if (val) {
form.name = val.name;
form.form = val.form || "";
form.strength = val.strength || "";
form.unit = val.unit || "";
form.description = val.description || "";
form.is_active = val.is_active;
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
if (isEdit.value && props.product) {
await updateImpProduct(study.currentStudy.id, props.product.id, form);
} else {
await createImpProduct(study.currentStudy.id, form);
}
ElMessage.success("保存成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
</script>
@@ -1,130 +0,0 @@
<template>
<el-dialog :model-value="modelValue" title="新增交易" width="560px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="中心" prop="site_id">
<SiteSelect v-model="form.site_id" :study-id="study.currentStudy?.id || null" />
</el-form-item>
<el-form-item label="批次" prop="batch_id">
<el-select v-model="form.batch_id" placeholder="请选择批次">
<el-option v-for="b in batches" :key="b.id" :label="b.batch_no" :value="b.id" />
</el-select>
</el-form-item>
<el-form-item label="交易类型" prop="tx_type">
<el-select v-model="form.tx_type" placeholder="请选择类型">
<el-option
v-for="t in txTypes"
:key="t"
:label="`${txTypeLabel(t)}(${t})`"
:value="t"
/>
</el-select>
</el-form-item>
<el-form-item label="数量" prop="quantity">
<el-input-number v-model="form.quantity" :min="1" :step="1" />
</el-form-item>
<el-form-item label="受试者" :required="form.tx_type === 'DISPENSE'">
<SubjectSelect
v-model="form.subject_id"
:study-id="study.currentStudy?.id || null"
placeholder="发放时必填"
/>
</el-form-item>
<el-form-item label="日期" prop="tx_date">
<el-date-picker v-model="form.tx_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="单号/备注">
<el-input v-model="form.reference" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.notes" type="textarea" rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createImpTransaction } from "../api/impTransactions";
import SiteSelect from "./selectors/SiteSelect.vue";
import SubjectSelect from "./selectors/SubjectSelect.vue";
const props = defineProps<{ modelValue: boolean; batches: any[] }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const txTypes = ["RECEIPT", "DISPENSE", "RETURN", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"];
const form = reactive({
site_id: "",
batch_id: "",
subject_id: "",
tx_type: "RECEIPT",
quantity: 1,
tx_date: "",
reference: "",
notes: "",
});
const rules: FormRules = {
site_id: [{ required: true, message: "请选择中心", trigger: "change" }],
batch_id: [{ required: true, message: "请选择批次", trigger: "change" }],
tx_type: [{ required: true, message: "请选择类型", trigger: "change" }],
quantity: [{ required: true, message: "请输入数量", trigger: "blur" }],
tx_date: [{ required: true, message: "请选择日期", trigger: "change" }],
};
watch(
() => form.tx_type,
(val) => {
if (val !== "DISPENSE") {
form.subject_id = "";
}
}
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
if (form.tx_type === "DISPENSE" && !form.subject_id) {
ElMessage.error("发放时受试者必填");
return;
}
submitting.value = true;
try {
const payload = {
...form,
site_id: form.site_id || null,
subject_id: form.subject_id || null,
};
await createImpTransaction(study.currentStudy.id, payload);
ElMessage.success("创建成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "创建失败");
} finally {
submitting.value = false;
}
};
const txTypeLabel = (t: string) =>
({
RECEIPT: "入库",
DISPENSE: "发放",
RETURN: "回收",
DESTROY: "销毁",
TRANSFER_IN: "调入",
TRANSFER_OUT: "调出",
}[t] || t);
</script>
-170
View File
@@ -1,170 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑问题' : '新增问题'" v-model="visible" width="600px" @close="onClose" class="ctms-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="受试者">
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可选" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="等级" prop="level">
<el-select v-model="form.level" placeholder="请选择">
<el-option v-for="l in levels" :key="l" :label="levelLabel(l)" :value="l" />
</el-select>
</el-form-item>
<el-form-item label="截止日期" prop="due_date">
<el-date-picker v-model="form.due_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<div class="footer-right">
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createIssue, updateIssue } from "../api/issues";
import { useStudyStore } from "../store/study";
import SubjectSelect from "./selectors/SubjectSelect.vue";
interface Props {
modelValue: boolean;
issue?: Record<string, any>;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const categories = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"];
const levels = ["LOW", "MEDIUM", "HIGH", "CRITICAL"];
const form = reactive({
title: "",
subject_id: "",
category: "ISSUE",
level: "MEDIUM",
due_date: "",
description: "",
});
const rules: FormRules = {
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
category: [{ required: true, message: "请选择类别", trigger: "change" }],
level: [{ required: true, message: "请选择等级", trigger: "change" }],
};
const isEdit = computed(() => !!props.issue);
watch(
() => props.issue,
(val) => {
if (val) {
Object.assign(form, {
title: val.title || "",
subject_id: val.subject_id || "",
category: val.category || "ISSUE",
level: val.level || "MEDIUM",
due_date: val.due_date || "",
description: val.description || "",
});
} else {
Object.assign(form, {
title: "",
subject_id: "",
category: "ISSUE",
level: "MEDIUM",
due_date: "",
description: "",
});
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload = { ...form };
if (isEdit.value && props.issue) {
await updateIssue(study.currentStudy.id, props.issue.id, payload);
} else {
await createIssue(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
const categoryLabel = (c: string) =>
({
RISK: "风险",
ISSUE: "问题",
PROTOCOL_DEVIATION: "方案偏差",
}[c] || c);
const levelLabel = (l: string) =>
({
LOW: "低",
MEDIUM: "中",
HIGH: "高",
CRITICAL: "关键",
}[l] || l);
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 6px;
}
:deep(.el-dialog__body) {
padding-top: 12px;
}
:deep(.el-dialog__footer) {
display: flex;
justify-content: flex-end;
align-items: center;
}
</style>
+52 -45
View File
@@ -35,52 +35,53 @@
<template v-if="study.currentStudy">
<div class="menu-divider">当前项目</div>
<el-menu-item index="/study/home">
<el-menu-item index="/project/overview">
<el-icon><House /></el-icon>
<span>项目</span>
<span>项目</span>
</el-menu-item>
<el-menu-item index="/study/milestones">
<el-icon><Calendar /></el-icon>
<span>伦理与启动</span>
</el-menu-item>
<el-menu-item index="/study/subjects">
<el-icon><User /></el-icon>
<span>受试者</span>
</el-menu-item>
<el-menu-item index="/study/aes">
<el-icon><WarningFilled /></el-icon>
<span>不良事件</span>
</el-menu-item>
<el-menu-item index="/study/issues">
<el-icon><Flag /></el-icon>
<span>风险与问题</span>
</el-menu-item>
<el-menu-item index="/study/data-queries">
<el-icon><ChatDotRound /></el-icon>
<span>数据问题</span>
</el-menu-item>
<el-menu-item index="/study/verifications">
<el-icon><CircleCheck /></el-icon>
<span>数据核查</span>
</el-menu-item>
<el-sub-menu index="imp">
<el-sub-menu index="finance">
<template #title>
<el-icon><Coin /></el-icon>
<span>费用管理</span>
</template>
<el-menu-item index="/finance/contracts">合同费用</el-menu-item>
<el-menu-item index="/finance/special">特殊费用</el-menu-item>
</el-sub-menu>
<el-sub-menu index="drug">
<template #title>
<el-icon><Box /></el-icon>
<span>药品管理</span>
</template>
<el-menu-item index="/study/imp/inventory">库存</el-menu-item>
<el-menu-item index="/study/imp/products">产品</el-menu-item>
<el-menu-item index="/study/imp/batches">批次</el-menu-item>
<el-menu-item index="/study/imp/transactions">交易台账</el-menu-item>
<el-menu-item index="/drug/shipments">运输/流向</el-menu-item>
</el-sub-menu>
<el-menu-item index="/study/finance">
<el-icon><Coin /></el-icon>
<span>费用管</span>
<el-menu-item index="/startup/feasibility-ethics">
<el-icon><Calendar /></el-icon>
<span>立项与伦</span>
</el-menu-item>
<el-menu-item index="/study/faq">
<el-icon><Notebook /></el-icon>
<span>知识库</span>
<el-menu-item index="/startup/meeting-auth">
<el-icon><CircleCheck /></el-icon>
<span>启动与授权</span>
</el-menu-item>
<el-menu-item index="/subjects">
<el-icon><User /></el-icon>
<span>受试者管理</span>
</el-menu-item>
<el-menu-item index="/monitoring">
<el-icon><ChatDotRound /></el-icon>
<span>监查</span>
</el-menu-item>
<el-menu-item index="/audit">
<el-icon><Flag /></el-icon>
<span>稽查</span>
</el-menu-item>
<el-sub-menu index="knowledge">
<template #title>
<el-icon><Notebook /></el-icon>
<span>知识库</span>
</template>
<el-menu-item index="/knowledge/medical-consult">医学咨询</el-menu-item>
<el-menu-item index="/knowledge/notes">注意事项</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</el-aside>
@@ -148,7 +149,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import StudySelector from "./StudySelector.vue";
import {
Monitor, User, Suitcase, House, Calendar, WarningFilled, Flag, ChatDotRound,
Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton
} from "@element-plus/icons-vue";
@@ -161,13 +162,19 @@ const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole =
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const activeMenu = computed(() => {
const path = route.path;
if (path.startsWith("/study/milestones/")) return "/study/milestones";
if (path.startsWith("/study/subjects/")) return "/study/subjects";
if (path.startsWith("/study/aes/")) return "/study/aes";
if (path.startsWith("/study/issues/")) return "/study/issues";
if (path.startsWith("/study/data-queries/")) return "/study/data-queries";
if (path.startsWith("/study/finance/")) return "/study/finance";
if (path.startsWith("/study/faq/")) return "/study/faq";
if (path.startsWith("/project/")) return "/project/overview";
if (path.startsWith("/finance/contracts")) return "/finance/contracts";
if (path.startsWith("/finance/special")) return "/finance/special";
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
return "/startup/meeting-auth";
}
if (path.startsWith("/subjects")) return "/subjects";
if (path.startsWith("/monitoring")) return "/monitoring";
if (path.startsWith("/audit")) return "/audit";
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
if (path.startsWith("/knowledge/notes")) return "/knowledge/notes";
if (path.startsWith("/projects/")) return "/admin/projects";
if (path.startsWith("/admin/projects/")) return "/admin/projects";
return path;
-177
View File
@@ -1,177 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑节点' : '新增节点'" v-model="visible" width="520px" @close="onClose">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" placeholder="请选择">
<el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="中心" prop="site_id">
<el-select v-model="form.site_id" placeholder="请选择中心" filterable>
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="负责人" prop="owner_id">
<el-select v-model="form.owner_id" placeholder="请选择负责人" filterable>
<el-option v-for="m in memberOptions" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</el-form-item>
<el-form-item label="计划日期" prop="planned_date">
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="实际日期" prop="actual_date">
<el-date-picker v-model="form.actual_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择">
<el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="notes">
<el-input v-model="form.notes" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createMilestone, updateMilestone } from "../api/milestones";
import { useStudyStore } from "../store/study";
import { milestoneStatusOptions, milestoneTypeOptions } from "../dictionaries/milestone.dict";
interface Props {
modelValue: boolean;
milestone?: Record<string, any>;
sites?: any[];
members?: any[];
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "success"]);
const formRef = ref<FormInstance>();
const study = useStudyStore();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const types = milestoneTypeOptions;
const statuses = milestoneStatusOptions;
const form = reactive({
name: "",
type: "",
site_id: "",
owner_id: "",
planned_date: "",
actual_date: "",
status: "NOT_STARTED",
notes: "",
});
const rules: FormRules = {
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
type: [{ required: true, message: "请选择类型", trigger: "change" }],
site_id: [{ required: true, message: "请选择中心", trigger: "change" }],
owner_id: [{ required: true, message: "请选择负责人", trigger: "change" }],
planned_date: [{ required: true, message: "请选择计划日期", trigger: "change" }],
};
const submitting = ref(false);
const isEdit = computed(() => !!props.milestone);
const memberOptions = computed(() => {
const memberList = (props.members || []).filter((m: any) => m.is_active !== false);
const seen = new Set<string>();
return memberList
.map((m: any) => {
const user = m.user || {};
const label = user.full_name || user.display_name || user.username || user.email || m.username || m.email || "—";
return { value: m.user_id, label };
})
.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
});
const siteOptions = computed(() =>
(props.sites || []).map((s: any) => ({
value: s.id,
label: s.name || "—",
}))
);
watch(
() => props.milestone,
(val) => {
if (val) {
form.name = val.name || "";
form.type = val.type || "";
form.site_id = val.site_id || "";
form.owner_id = val.owner_id || "";
form.planned_date = val.planned_date || "";
form.actual_date = val.actual_date || "";
form.status = val.status || "NOT_STARTED";
form.notes = val.notes || "";
} else {
form.name = "";
form.type = "";
form.site_id = "";
form.owner_id = "";
form.planned_date = "";
form.actual_date = "";
form.status = "NOT_STARTED";
form.notes = "";
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload = {
...form,
actual_date: form.actual_date || null,
};
if (isEdit.value && props.milestone) {
await updateMilestone(study.currentStudy.id, props.milestone.id, payload);
} else {
await createMilestone(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
</script>
@@ -0,0 +1,92 @@
<template>
<div class="module-page">
<div class="module-header">
<div>
<h1 class="module-title">{{ title }}</h1>
<p v-if="subtitle" class="module-subtitle">{{ subtitle }}</p>
</div>
<slot name="actions" />
</div>
<div class="module-content">
<div class="module-list">
<div class="module-list-header">
<span class="list-title">{{ listTitle }}</span>
<span class="list-note">列表区域预留</span>
</div>
<StateEmpty :title="emptyTitle" :description="emptyDescription" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import StateEmpty from "./StateEmpty.vue";
withDefaults(
defineProps<{
title: string;
subtitle?: string;
listTitle?: string;
emptyTitle?: string;
emptyDescription?: string;
}>(),
{
subtitle: "",
listTitle: "列表",
emptyTitle: "功能建设中",
emptyDescription: "该模块基础骨架已建立,后续将补充完整交互与数据能力。",
}
);
</script>
<style scoped>
.module-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.module-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.module-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--ctms-text-main);
}
.module-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.module-list {
background-color: #fff;
border: 1px solid var(--ctms-border-color);
border-radius: var(--ctms-radius);
padding: 20px;
}
.module-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.list-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.list-note {
font-size: 12px;
color: var(--ctms-text-disabled);
}
</style>
+8 -9
View File
@@ -24,20 +24,19 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import {
Pointer, ArrowRight, Timer, UserFilled, Warning,
QuestionFilled, Management, Money, Collection
Pointer, ArrowRight, Timer, UserFilled,
Management, Money, Collection
} from "@element-plus/icons-vue";
const router = useRouter();
const actions = [
{ label: "伦理与启动", path: "/study/milestones", icon: Timer },
{ label: "受试者", path: "/study/subjects", icon: UserFilled },
{ label: "不良事件", path: "/study/aes", icon: Warning },
{ label: "数据问题", path: "/study/data-queries", icon: QuestionFilled },
{ label: "药品库存", path: "/study/imp/inventory", icon: Management },
{ label: "费用管理", path: "/study/finance", icon: Money },
{ label: "项目知识库", path: "/study/faq", icon: Collection },
{ label: "立项与伦理", path: "/startup/feasibility-ethics", icon: Timer },
{ label: "启动与授权", path: "/startup/meeting-auth", icon: Timer },
{ label: "受试者管理", path: "/subjects", icon: UserFilled },
{ label: "药品流向", path: "/drug/shipments", icon: Management },
{ label: "费用管理", path: "/finance/contracts", icon: Money },
{ label: "知识库", path: "/knowledge/medical-consult", icon: Collection },
];
const go = (path: string) => router.push(path);
+1 -1
View File
@@ -64,7 +64,7 @@ const onCommand = (cmd: any) => {
}
if (cmd?.type === "switch" && cmd.study) {
study.setCurrentStudy(cmd.study as Study);
router.push("/study/home");
router.push("/project/overview");
}
};
-128
View File
@@ -1,128 +0,0 @@
<template>
<el-dialog title="新增受试者" v-model="visible" width="520px" @close="onClose" class="ctms-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
<el-form-item label="中心" prop="site_id">
<el-select v-model="form.site_id" placeholder="请选择" filterable>
<el-option v-for="s in sites" :key="s.id" :label="s.name || s.id" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="受试者编号" prop="subject_no">
<el-input v-model="form.subject_no" />
</el-form-item>
<el-form-item label="筛选日期" prop="screening_date">
<el-date-picker v-model="form.screening_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
</el-form>
<template #footer>
<div class="footer-left">
<el-checkbox v-model="keepCreating">提交后继续新建</el-checkbox>
</div>
<div class="footer-right">
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createSubject } from "../api/subjects";
import { useStudyStore } from "../store/study";
interface Props {
modelValue: boolean;
sites: any[];
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const form = reactive({
site_id: "",
subject_no: "",
screening_date: "",
});
const keepCreating = ref(false);
const rules: FormRules = {
site_id: [{ required: true, message: "请选择中心", trigger: "change" }],
subject_no: [{ required: true, message: "请输入受试者编号", trigger: "blur" }],
};
const onClose = () => {
visible.value = false;
};
const resetForm = () => {
const studyId = study.currentStudy?.id;
const recentSite = studyId ? localStorage.getItem(`recent_site_${studyId}`) : null;
form.site_id = recentSite || "";
form.subject_no = "";
form.screening_date = "";
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const { data } = await createSubject(study.currentStudy.id, { ...form });
ElMessage.success("提交成功");
if (study.currentStudy?.id) {
localStorage.setItem(`recent_site_${study.currentStudy.id}`, form.site_id || "");
}
emit("success");
if (keepCreating.value) {
resetForm();
} else {
onClose();
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
resetForm();
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 8px;
}
:deep(.el-dialog__body) {
padding-top: 12px;
}
:deep(.el-dialog__footer) {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
color: var(--ctms-text-secondary);
}
</style>
@@ -1,41 +0,0 @@
<template>
<el-table :data="verifications" v-loading="loading" style="width: 100%">
<el-table-column prop="subject_id" label="受试者">
<template #default="scope">
{{ subjectMap[scope.row.subject_id] || scope.row.subject_no || scope.row.subject_id || "-" }}
</template>
</el-table-column>
<el-table-column prop="level" label="类型" width="100" />
<el-table-column label="完成度" width="180">
<template #default="scope">
<el-progress :percentage="scope.row.percent || 0" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column prop="last_verified_at" label="最近核查" width="140">
<template #default="scope">{{ displayDate(scope.row.last_verified_at) }}</template>
</el-table-column>
<el-table-column prop="verifier_id" label="核查人" width="160">
<template #default="scope">
{{ verifierMap[scope.row.verifier_id] || "-" }}
</template>
</el-table-column>
<el-table-column v-if="canEdit" label="操作" width="120">
<template #default="scope">
<el-button type="primary" link size="small" @click="$emit('edit', scope.row)">更新进度</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { displayDate } from "../utils/display";
defineProps<{
verifications: any[];
canEdit: boolean;
loading: boolean;
subjectMap: Record<string, string>;
verifierMap: Record<string, string>;
}>();
defineEmits(["edit"]);
</script>
-72
View File
@@ -1,72 +0,0 @@
<template>
<el-card>
<div class="header">
<h4>访视计划</h4>
</div>
<el-table :data="visits" style="width: 100%" v-loading="loading">
<el-table-column prop="visit_code" label="代码" width="120" />
<el-table-column prop="visit_name" label="名称" />
<el-table-column prop="planned_date" label="计划日期" width="140" />
<el-table-column prop="actual_date" label="实际日期" width="140" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column v-if="canEdit" label="操作" width="200">
<template #default="scope">
<el-button type="primary" link size="small" @click="markDone(scope.row)">标记完成</el-button>
<el-button type="danger" link size="small" @click="markMissed(scope.row)">标记缺失</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { updateVisit } from "../api/visits";
import { useStudyStore } from "../store/study";
interface Props {
visits: any[];
subjectId: string;
canEdit: boolean;
onRefresh: () => void;
}
const props = defineProps<Props>();
const study = useStudyStore();
const loading = ref(false);
const doUpdate = async (visitId: string, payload: Record<string, any>) => {
if (!study.currentStudy) return;
loading.value = true;
try {
await updateVisit(study.currentStudy.id, props.subjectId, visitId, payload);
ElMessage.success("更新成功");
props.onRefresh();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
} finally {
loading.value = false;
}
};
const markDone = async (row: any) => {
await ElMessageBox.confirm("确认标记为完成?", "提示");
const today = new Date().toISOString().slice(0, 10);
doUpdate(row.id, { status: "DONE", actual_date: today });
};
const markMissed = async (row: any) => {
await ElMessageBox.confirm("确认标记为缺失?", "提示");
doUpdate(row.id, { status: "MISSED", actual_date: null });
};
</script>
<style scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
</style>
@@ -1,90 +0,0 @@
<template>
<el-select
:model-value="modelValue"
filterable
remote
clearable
:remote-method="onSearch"
:loading="loading"
:placeholder="placeholder || '请选择中心'"
style="width: 100%"
:disabled="disabled || !studyId"
@update:model-value="(val) => emit('update:modelValue', val)"
>
<el-option v-for="s in options" :key="s.id" :label="formatSite(s)" :value="s.id" />
<template #empty>
<div class="empty">{{ loading ? "加载中..." : "无匹配结果" }}</div>
</template>
</el-select>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, ref, watch } from "vue";
import { fetchSites } from "../../api/sites";
interface Props {
modelValue: string | null;
studyId: string | null;
placeholder?: string;
disabled?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue"]);
const options = ref<any[]>([]);
const loading = ref(false);
let timer: number | undefined;
const disabled = computed(() => props.disabled ?? false);
const formatSite = (s: any) => {
if (!s) return "";
return s.city ? `${s.name} (${s.city})` : s.name;
};
const load = async (keyword = "") => {
if (!props.studyId) {
options.value = [];
return;
}
loading.value = true;
try {
const { data } = await fetchSites(props.studyId, { keyword, limit: 20 });
options.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "中心加载失败");
} finally {
loading.value = false;
}
};
const onSearch = (keyword: string) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => load(keyword), 300);
};
watch(
() => props.studyId,
() => {
load("");
},
{ immediate: true }
);
watch(
() => props.modelValue,
(val) => {
if (val && options.value.length === 0) {
load("");
}
}
);
</script>
<style scoped>
.empty {
padding: 8px;
color: #999;
}
</style>
@@ -1,98 +0,0 @@
<template>
<el-select
:model-value="modelValue"
filterable
remote
clearable
:remote-method="onSearch"
:loading="loading"
:placeholder="placeholder || '请选择受试者'"
style="width: 100%"
:disabled="disabled || !studyId"
@update:model-value="(val) => emit('update:modelValue', val)"
>
<el-option
v-for="s in options"
:key="s.id"
:label="formatSubject(s)"
:value="s.id"
/>
<template #empty>
<div class="empty">{{ loading ? "加载中..." : "无匹配结果" }}</div>
</template>
</el-select>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, ref, watch } from "vue";
import { fetchSubjects } from "../../api/subjects";
interface Props {
modelValue: string | null;
studyId: string | null;
placeholder?: string;
status?: string;
disabled?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue"]);
const options = ref<any[]>([]);
const loading = ref(false);
let timer: number | undefined;
const disabled = computed(() => props.disabled ?? false);
const formatSubject = (s: any) => {
if (!s) return "";
return s.status ? `${s.subject_no || s.id} (${s.status})` : s.subject_no || s.id;
};
const load = async (keyword = "") => {
if (!props.studyId) {
options.value = [];
return;
}
loading.value = true;
try {
const params: Record<string, any> = { keyword, limit: 20 };
if (props.status) params.status = props.status;
const { data } = await fetchSubjects(props.studyId, params);
options.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "受试者加载失败");
} finally {
loading.value = false;
}
};
const onSearch = (keyword: string) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => load(keyword), 300);
};
watch(
() => props.studyId,
() => {
load("");
},
{ immediate: true }
);
watch(
() => props.modelValue,
(val) => {
if (val && options.value.length === 0) {
load("");
}
}
);
</script>
<style scoped>
.empty {
padding: 8px;
color: #999;
}
</style>
@@ -1,81 +0,0 @@
<template>
<el-select
:model-value="modelValue"
:multiple="multiple"
filterable
remote
clearable
:remote-method="onSearch"
:loading="loading"
:placeholder="placeholder || '请选择用户'"
:disabled="disabled"
style="width: 100%"
@update:model-value="(val) => emit('update:modelValue', val)"
>
<el-option
v-for="u in options"
:key="u.id"
:label="`${u.username}${u.role ? ' (' + u.role + ')' : ''}`"
:value="u.id"
/>
<template #empty>
<div class="empty">{{ loading ? "加载中..." : "无匹配结果" }}</div>
</template>
</el-select>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, ref, watch } from "vue";
import { fetchUsers } from "../../api/users";
interface Props {
modelValue: string | string[] | null;
placeholder?: string;
multiple?: boolean;
disabled?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue"]);
const options = ref<any[]>([]);
const loading = ref(false);
let timer: number | undefined;
const disabled = computed(() => props.disabled ?? false);
const load = async (keyword = "") => {
loading.value = true;
try {
const { data } = await fetchUsers({ keyword, limit: 20 });
options.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "用户加载失败");
} finally {
loading.value = false;
}
};
const onSearch = (keyword: string) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => load(keyword), 300);
};
watch(
() => props.modelValue,
(val) => {
// 初次渲染时拉一版数据,确保已选项有 label
if (val && options.value.length === 0) {
load("");
}
},
{ immediate: true }
);
</script>
<style scoped>
.empty {
padding: 8px;
color: #999;
}
</style>
-21
View File
@@ -1,21 +0,0 @@
import type { Dict } from "./types";
import { createDict } from "./utils";
const seriousnessItems = [
{ value: "NON_SERIOUS", label: "否", order: 1 },
{ value: "SERIOUS", label: "是", order: 2, color: "danger" },
];
const severityItems = [
{ value: "G1", label: "I级", order: 1 },
{ value: "G2", label: "II级", order: 2 },
{ value: "G3", label: "III级", order: 3 },
{ value: "G4", label: "IV级", order: 4, color: "danger" },
{ value: "G5", label: "V级", order: 5, color: "danger" },
{ value: "MILD", label: "轻度", order: 6 },
{ value: "MODERATE", label: "中度", order: 7 },
{ value: "SEVERE", label: "重度", order: 8, color: "danger" },
];
export const aeSeriousnessDict: Dict = createDict(seriousnessItems);
export const aeSeverityDict: Dict = createDict(severityItems);
-13
View File
@@ -1,13 +0,0 @@
import type { Dict } from "./types";
import { createDict } from "./utils";
import { statusDict } from "./status.dict";
const categoryItems = [
{ value: "SITE_FEE", label: "中心费用", order: 1 },
{ value: "SUBJECT_FEE", label: "受试者费用", order: 2 },
{ value: "TRAVEL_FEE", label: "差旅费用", order: 3 },
{ value: "OTHER", label: "其他", order: 99 },
];
export const financeCategoryDict: Dict = createDict(categoryItems);
export const financeStatusDict = statusDict;
-12
View File
@@ -1,12 +0,0 @@
import type { Dict } from "./types";
import { createDict } from "./utils";
const transactionItems = [
{ value: "RECEIPT", label: "接收", order: 1 },
{ value: "DISPENSE", label: "发药", order: 2 },
{ value: "RETURN", label: "回收", order: 3 },
{ value: "DESTROY", label: "销毁", order: 4 },
{ value: "TRANSFER", label: "转移", order: 5 },
];
export const impTransactionTypeDict: Dict = createDict(transactionItems);
-4
View File
@@ -1,8 +1,4 @@
export * from "./types";
export * from "./utils";
export * from "./status.dict";
export * from "./priority.dict";
export * from "./role.dict";
export * from "./ae.dict";
export * from "./finance.dict";
export * from "./imp.dict";
@@ -1,41 +0,0 @@
import { createDict, getDictColor, getDictLabel } from "./utils";
import type { Dict } from "./types";
export const milestoneTypeDict: Record<string, string> = {
ETHICS_SUBMISSION: "伦理递交",
ETHICS_APPROVAL: "伦理批件",
SITE_INITIATION: "中心启动",
SIV: "启动会",
FPI: "首例入组",
LPI: "末例入组",
DBL: "揭盲",
CSR: "总结报告",
};
const milestoneStatusItems = [
{ value: "NOT_STARTED", label: "未开始", color: "info", order: 1 },
{ value: "IN_PROGRESS", label: "进行中", color: "warning", order: 2 },
{ value: "DONE", label: "已完成", color: "success", order: 3 },
{ value: "COMPLETED", label: "已完成", color: "success", order: 3 },
{ value: "BLOCKED", label: "受阻", color: "danger", order: 4 },
];
export const milestoneStatusDict: Dict = createDict(milestoneStatusItems);
export const milestoneTypeOptions = Object.entries(milestoneTypeDict).map(([value, label]) => ({
value,
label,
}));
export const milestoneStatusOptions = ["NOT_STARTED", "IN_PROGRESS", "DONE", "BLOCKED"].map((value) => ({
value,
label: getDictLabel(milestoneStatusDict, value) || value,
color: getDictColor(milestoneStatusDict, value),
}));
export const getMilestoneTypeLabel = (value?: string | null) => milestoneTypeDict[value || ""] || value || "—";
export const getMilestoneStatusLabel = (value?: string | null) =>
getDictLabel(milestoneStatusDict, value) || value || "—";
export const getMilestoneStatusColor = (value?: string | null) => getDictColor(milestoneStatusDict, value) || "info";
@@ -1,10 +0,0 @@
import type { Dict } from "./types";
import { createDict } from "./utils";
const items = [
{ value: "LOW", label: "低", color: "info", order: 1 },
{ value: "MEDIUM", label: "中", color: "warning", order: 2 },
{ value: "HIGH", label: "高", color: "danger", order: 3 },
];
export const priorityDict: Dict = createDict(items);
+242 -124
View File
@@ -5,27 +5,8 @@ import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import StudyHome from "../views/StudyHome.vue";
import Milestones from "../views/Milestones.vue";
import MilestoneDetail from "../views/MilestoneDetail.vue";
import Subjects from "../views/Subjects.vue";
import SubjectDetail from "../views/SubjectDetail.vue";
import MyWorkbench from "../views/workbench/MyWorkbench.vue";
import Aes from "../views/Aes.vue";
import AeDetail from "../views/AeDetail.vue";
import DataQueries from "../views/DataQueries.vue";
import DataQueryDetail from "../views/DataQueryDetail.vue";
import Imp from "../views/Imp.vue";
import ImpProducts from "../views/ImpProducts.vue";
import ImpBatches from "../views/ImpBatches.vue";
import ImpInventory from "../views/ImpInventory.vue";
import ImpTransactions from "../views/ImpTransactions.vue";
import Finance from "../views/Finance.vue";
import FinanceDetail from "../views/FinanceDetail.vue";
import Faq from "../views/Faq.vue";
import FaqDetail from "../views/FaqDetail.vue";
import Issues from "../views/Issues.vue";
import IssueDetail from "../views/IssueDetail.vue";
import Verification from "../views/Verification.vue";
import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue";
import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
@@ -34,6 +15,35 @@ import ProjectMembers from "../views/admin/ProjectMembers.vue";
import AdminSites from "../views/admin/Sites.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue";
import FinanceContracts from "../views/ia/FinanceContracts.vue";
import FinanceSpecial from "../views/ia/FinanceSpecial.vue";
import DrugShipments from "../views/ia/DrugShipments.vue";
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
import SubjectManagement from "../views/ia/SubjectManagement.vue";
import MonitoringPlaceholder from "../views/ia/MonitoringPlaceholder.vue";
import AuditPlaceholder from "../views/ia/AuditPlaceholder.vue";
import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
import KnowledgeNotes from "../views/ia/KnowledgeNotes.vue";
import ContractForm from "../views/finance/ContractForm.vue";
import ContractDetail from "../views/finance/ContractDetail.vue";
import SpecialForm from "../views/finance/SpecialForm.vue";
import SpecialDetail from "../views/finance/SpecialDetail.vue";
import ShipmentForm from "../views/drug/ShipmentForm.vue";
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
import EthicsForm from "../views/startup/EthicsForm.vue";
import EthicsDetail from "../views/startup/EthicsDetail.vue";
import KickoffForm from "../views/startup/KickoffForm.vue";
import KickoffDetail from "../views/startup/KickoffDetail.vue";
import TrainingForm from "../views/startup/TrainingForm.vue";
import TrainingDetail from "../views/startup/TrainingDetail.vue";
import NoteForm from "../views/knowledge/NoteForm.vue";
import NoteDetail from "../views/knowledge/NoteDetail.vue";
import SubjectForm from "../views/subjects/SubjectForm.vue";
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
const routes: RouteRecordRaw[] = [
{
@@ -66,130 +76,238 @@ const routes: RouteRecordRaw[] = [
meta: { title: "个人设置" },
},
{
path: "study/home",
name: "StudyHome",
component: StudyHome,
meta: { title: "项目首页", requiresStudy: true },
path: "project/overview",
name: "ProjectOverview",
component: ProjectOverview,
meta: { title: "项目总览", requiresStudy: true },
},
{
path: "study/milestones",
name: "StudyMilestones",
component: Milestones,
meta: { title: "伦理与启动", requiresStudy: true },
path: "finance/contracts",
name: "FinanceContracts",
component: FinanceContracts,
meta: { title: "合同费用", requiresStudy: true },
},
{
path: "study/milestones/:milestoneId",
name: "StudyMilestoneDetail",
component: MilestoneDetail,
meta: { title: "节点详情", requiresStudy: true },
path: "finance/contracts/new",
name: "FinanceContractNew",
component: ContractForm,
meta: { title: "新增合同费用", requiresStudy: true },
},
{
path: "study/subjects",
name: "StudySubjects",
component: Subjects,
meta: { title: "受试者", requiresStudy: true },
path: "finance/contracts/:contractId",
name: "FinanceContractDetail",
component: ContractDetail,
meta: { title: "合同费用详情", requiresStudy: true },
},
{
path: "study/subjects/:subjectId",
name: "StudySubjectDetail",
path: "finance/contracts/:contractId/edit",
name: "FinanceContractEdit",
component: ContractForm,
meta: { title: "编辑合同费用", requiresStudy: true },
},
{
path: "finance/special",
name: "FinanceSpecial",
component: FinanceSpecial,
meta: { title: "特殊费用", requiresStudy: true },
},
{
path: "finance/special/new",
name: "FinanceSpecialNew",
component: SpecialForm,
meta: { title: "新增特殊费用", requiresStudy: true },
},
{
path: "finance/special/:specialId",
name: "FinanceSpecialDetail",
component: SpecialDetail,
meta: { title: "特殊费用详情", requiresStudy: true },
},
{
path: "finance/special/:specialId/edit",
name: "FinanceSpecialEdit",
component: SpecialForm,
meta: { title: "编辑特殊费用", requiresStudy: true },
},
{
path: "drug/shipments",
name: "DrugShipments",
component: DrugShipments,
meta: { title: "药品流向", requiresStudy: true },
},
{
path: "drug/shipments/new",
name: "DrugShipmentNew",
component: ShipmentForm,
meta: { title: "新增运输记录", requiresStudy: true },
},
{
path: "drug/shipments/:shipmentId",
name: "DrugShipmentDetail",
component: ShipmentDetail,
meta: { title: "运输记录详情", requiresStudy: true },
},
{
path: "drug/shipments/:shipmentId/edit",
name: "DrugShipmentEdit",
component: ShipmentForm,
meta: { title: "编辑运输记录", requiresStudy: true },
},
{
path: "startup/feasibility-ethics",
name: "StartupFeasibilityEthics",
component: StartupFeasibilityEthics,
meta: { title: "立项与伦理", requiresStudy: true },
},
{
path: "startup/feasibility/new",
name: "StartupFeasibilityNew",
component: FeasibilityForm,
meta: { title: "新增立项记录", requiresStudy: true },
},
{
path: "startup/feasibility/:recordId",
name: "StartupFeasibilityDetail",
component: FeasibilityDetail,
meta: { title: "立项记录详情", requiresStudy: true },
},
{
path: "startup/feasibility/:recordId/edit",
name: "StartupFeasibilityEdit",
component: FeasibilityForm,
meta: { title: "编辑立项记录", requiresStudy: true },
},
{
path: "startup/ethics/new",
name: "StartupEthicsNew",
component: EthicsForm,
meta: { title: "新增伦理记录", requiresStudy: true },
},
{
path: "startup/ethics/:recordId",
name: "StartupEthicsDetail",
component: EthicsDetail,
meta: { title: "伦理记录详情", requiresStudy: true },
},
{
path: "startup/ethics/:recordId/edit",
name: "StartupEthicsEdit",
component: EthicsForm,
meta: { title: "编辑伦理记录", requiresStudy: true },
},
{
path: "startup/meeting-auth",
name: "StartupMeetingAuth",
component: StartupMeetingAuth,
meta: { title: "启动与授权", requiresStudy: true },
},
{
path: "startup/kickoff/new",
name: "StartupKickoffNew",
component: KickoffForm,
meta: { title: "新增启动会", requiresStudy: true },
},
{
path: "startup/kickoff/:meetingId",
name: "StartupKickoffDetail",
component: KickoffDetail,
meta: { title: "启动会详情", requiresStudy: true },
},
{
path: "startup/kickoff/:meetingId/edit",
name: "StartupKickoffEdit",
component: KickoffForm,
meta: { title: "编辑启动会", requiresStudy: true },
},
{
path: "startup/training/new",
name: "StartupTrainingNew",
component: TrainingForm,
meta: { title: "新增人员", requiresStudy: true },
},
{
path: "startup/training/:recordId",
name: "StartupTrainingDetail",
component: TrainingDetail,
meta: { title: "培训授权详情", requiresStudy: true },
},
{
path: "startup/training/:recordId/edit",
name: "StartupTrainingEdit",
component: TrainingForm,
meta: { title: "编辑培训授权", requiresStudy: true },
},
{
path: "subjects",
name: "SubjectManagement",
component: SubjectManagement,
meta: { title: "受试者管理", requiresStudy: true },
},
{
path: "subjects/new",
name: "SubjectNew",
component: SubjectForm,
meta: { title: "新增受试者", requiresStudy: true },
},
{
path: "subjects/:subjectId",
name: "SubjectDetail",
component: SubjectDetail,
meta: { title: "受试者详情", requiresStudy: true },
},
{
path: "study/aes",
name: "StudyAes",
component: Aes,
meta: { title: "AE", requiresStudy: true },
path: "subjects/:subjectId/edit",
name: "SubjectEdit",
component: SubjectForm,
meta: { title: "编辑受试者", requiresStudy: true },
},
{
path: "study/aes/:aeId",
name: "StudyAeDetail",
component: AeDetail,
meta: { title: "AE详情", requiresStudy: true },
path: "monitoring",
name: "MonitoringPlaceholder",
component: MonitoringPlaceholder,
meta: { title: "监查", requiresStudy: true },
},
{
path: "study/data-queries",
name: "StudyDataQueries",
component: DataQueries,
meta: { title: "数据问题", requiresStudy: true },
path: "audit",
name: "AuditPlaceholder",
component: AuditPlaceholder,
meta: { title: "稽查", requiresStudy: true },
},
{
path: "study/data-queries/:queryId",
name: "StudyDataQueryDetail",
component: DataQueryDetail,
meta: { title: "数据问题详情", requiresStudy: true },
path: "knowledge/medical-consult",
name: "KnowledgeMedicalConsult",
component: KnowledgeMedicalConsult,
meta: { title: "医学咨询", requiresStudy: true },
},
{
path: "study/issues",
name: "StudyIssues",
component: Issues,
meta: { title: "风险/问题", requiresStudy: true },
},
{
path: "study/issues/:issueId",
name: "StudyIssueDetail",
component: IssueDetail,
meta: { title: "风险详情", requiresStudy: true },
},
{
path: "study/imp",
name: "StudyImp",
component: Imp,
meta: { title: "药品", requiresStudy: true },
},
{
path: "study/imp/products",
name: "StudyImpProducts",
component: ImpProducts,
meta: { title: "IMP 产品", requiresStudy: true },
},
{
path: "study/imp/batches",
name: "StudyImpBatches",
component: ImpBatches,
meta: { title: "IMP 批次", requiresStudy: true },
},
{
path: "study/imp/inventory",
name: "StudyImpInventory",
component: ImpInventory,
meta: { title: "IMP 库存", requiresStudy: true },
},
{
path: "study/imp/transactions",
name: "StudyImpTransactions",
component: ImpTransactions,
meta: { title: "IMP 交易", requiresStudy: true },
},
{
path: "study/finance",
name: "StudyFinance",
component: Finance,
meta: { title: "费用", requiresStudy: true },
},
{
path: "study/finance/:itemId",
name: "StudyFinanceDetail",
component: FinanceDetail,
meta: { title: "费用详情", requiresStudy: true },
},
{
path: "study/faq",
name: "StudyFaq",
component: Faq,
meta: { title: "FAQ", requiresStudy: true },
},
{
path: "study/faq/:itemId",
name: "StudyFaqDetail",
path: "knowledge/medical-consult/:itemId",
name: "KnowledgeMedicalConsultDetail",
component: FaqDetail,
meta: { title: "FAQ 详情", requiresStudy: true },
meta: { title: "医学咨询详情", requiresStudy: true },
},
{
path: "study/verifications",
name: "StudyVerifications",
component: Verification,
meta: { title: "核查进度", requiresStudy: true },
path: "knowledge/notes",
name: "KnowledgeNotes",
component: KnowledgeNotes,
meta: { title: "注意事项", requiresStudy: true },
},
{
path: "knowledge/notes/new",
name: "KnowledgeNoteNew",
component: NoteForm,
meta: { title: "新增注意事项", requiresStudy: true },
},
{
path: "knowledge/notes/:noteId",
name: "KnowledgeNoteDetail",
component: NoteDetail,
meta: { title: "注意事项详情", requiresStudy: true },
},
{
path: "knowledge/notes/:noteId/edit",
name: "KnowledgeNoteEdit",
component: NoteForm,
meta: { title: "编辑注意事项", requiresStudy: true },
},
],
},
@@ -270,7 +388,7 @@ router.beforeEach(async (to, _from, next) => {
}
if ((to.path === "/login" || to.path === "/register") && token) {
if (!auth.forceLogin) {
next({ path: studyStore.currentStudy ? "/study/home" : "/workbench" });
next({ path: studyStore.currentStudy ? "/project/overview" : "/workbench" });
return;
}
}
@@ -282,16 +400,16 @@ router.beforeEach(async (to, _from, next) => {
const role = auth.user?.role;
const studyRole = studyStore.currentStudyRole;
if (!(role === "ADMIN" || role === "PM" || studyRole === "PM")) {
next({ path: "/study/home" });
next({ path: "/project/overview" });
return;
}
}
if (to.path.startsWith("/study") && !studyStore.currentStudy) {
if (to.meta.requiresStudy && !studyStore.currentStudy) {
next({ path: "/workbench" });
return;
}
if (to.path === "/workbench" && studyStore.currentStudy) {
next({ path: "/study/home" });
next({ path: "/project/overview" });
return;
}
next();
-19
View File
@@ -1,19 +0,0 @@
import type { StateMachine } from "./types";
export const aeMachine: StateMachine = {
states: {
NEW: { value: "NEW", label: "新增", color: "info" },
FOLLOW_UP: { value: "FOLLOW_UP", label: "随访中", color: "warning" },
CLOSED: { value: "CLOSED", label: "已关闭", color: "success", isTerminal: true },
},
actions: {
close: {
key: "close",
label: "关闭",
from: ["NEW", "FOLLOW_UP"],
to: "CLOSED",
confirm: true,
confirmText: "确定关闭该 AE?该操作不可撤销。",
},
},
};
@@ -1,43 +0,0 @@
import type { StateMachine } from "./types";
export const financeMachine: StateMachine = {
states: {
DRAFT: { value: "DRAFT", label: "草稿", color: "info" },
SUBMITTED: { value: "SUBMITTED", label: "已提交", color: "warning" },
APPROVED: { value: "APPROVED", label: "已审批", color: "success" },
REJECTED: { value: "REJECTED", label: "已驳回", color: "danger", isTerminal: true },
PAID: { value: "PAID", label: "已支付", color: "success", isTerminal: true },
},
actions: {
submit: {
key: "submit",
label: "提交",
from: ["DRAFT"],
to: "SUBMITTED",
},
approve: {
key: "approve",
label: "审批通过",
from: ["SUBMITTED"],
to: "APPROVED",
},
reject: {
key: "reject",
label: "驳回",
from: ["SUBMITTED"],
to: "REJECTED",
confirm: true,
confirmText: "确认驳回该费用吗?",
danger: true,
},
pay: {
key: "pay",
label: "确认支付",
from: ["APPROVED"],
to: "PAID",
confirm: true,
confirmText: "确认支付该费用吗?",
danger: true,
},
},
};
@@ -1,8 +0,0 @@
import type { StateMachine } from "./types";
export const impMachine: StateMachine = {
states: {
CREATED: { value: "CREATED", label: "已创建", color: "info" },
},
actions: {},
};
-4
View File
@@ -23,7 +23,3 @@ export const canDoAction = (machine: StateMachine, actionKey: string, currentSta
};
export * from "./types";
export * from "./subject.machine";
export * from "./ae.machine";
export * from "./finance.machine";
export * from "./imp.machine";
@@ -1,33 +0,0 @@
import type { StateMachine } from "./types";
export const subjectMachine: StateMachine = {
states: {
SCREENING: { value: "SCREENING", label: "筛选中", color: "info" },
ENROLLED: { value: "ENROLLED", label: "已入组", color: "success" },
COMPLETED: { value: "COMPLETED", label: "已完成", color: "success", isTerminal: true },
DROPPED: { value: "DROPPED", label: "已脱落", color: "danger", isTerminal: true },
},
actions: {
enroll: {
key: "enroll",
label: "标记入组",
from: ["SCREENING"],
to: "ENROLLED",
},
complete: {
key: "complete",
label: "标记完成",
from: ["ENROLLED"],
to: "COMPLETED",
},
drop: {
key: "drop",
label: "标记脱落",
from: ["SCREENING", "ENROLLED"],
to: "DROPPED",
danger: true,
confirm: true,
confirmText: "确定将该受试者标记为脱落?",
},
},
};
-13
View File
@@ -3,41 +3,28 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
const PERMISSIONS: Record<string, string[]> = {
"milestone.create": ["ADMIN", "PM", "CRA", "PV", "IMP"],
"subject.create": ["ADMIN", "PM", "CRA"],
"subject.enroll": ["ADMIN", "PM", "CRA"],
"subject.complete": ["ADMIN", "PM", "CRA"],
"subject.drop": ["ADMIN", "PM", "CRA"],
"ae.create": ["ADMIN", "PM", "CRA", "PV"],
"ae.close": ["ADMIN", "PM", "PV", "CRA"],
"issue.create": ["ADMIN", "PM", "PV"],
"issue.close": ["ADMIN", "PM", "PV"],
"finance.create": ["ADMIN", "PM", "CRA"],
"finance.approve": ["ADMIN", "PM"],
"finance.pay": ["ADMIN", "PM"],
"imp.transaction": ["ADMIN", "PM", "IMP"],
"faq.edit": ["ADMIN", "PM"],
"faq.create": ["ADMIN", "PM", "CRA", "PV", "IMP"],
"faq.reply": ["ADMIN", "PM", "CRA", "PV", "IMP"],
"dataquery.edit": ["ADMIN", "PM", "CRA"],
"project.members.manage": ["ADMIN", "PM"],
"site.manage": ["ADMIN", "PM"],
"site.cra.bind": ["ADMIN", "PM"],
};
const REASONS: Record<string, string> = {
"milestone.create": "仅项目成员可维护里程碑",
"subject.enroll": "仅 PM/CRA 可更新受试者状态",
"subject.complete": "仅 PM/CRA 可更新受试者状态",
"subject.drop": "仅 PM/CRA 可更新受试者状态",
"ae.close": "仅 PM/PV/ADMIN 可关闭 AE",
"finance.approve": "审批需要 PM/ADMIN 权限",
"finance.pay": "支付确认需要 PM/ADMIN 权限",
"imp.transaction": "仅 IMP/PM/ADMIN 可操作台账",
"faq.edit": "仅 PM/ADMIN 可维护 FAQ",
"faq.create": "仅项目成员可新建 FAQ",
"faq.reply": "仅项目成员可回复 FAQ",
"dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题",
"project.members.manage": "仅 ADMIN / PM 可调整项目成员",
"site.manage": "仅 ADMIN / PM 可管理中心",
"site.cra.bind": "仅 ADMIN / PM 可绑定 CRA",
-497
View File
@@ -1,497 +0,0 @@
<template>
<div class="ctms-page" v-if="ae">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">不良事件</h1>
</div>
<div class="ctms-page-actions">
<el-button v-if="canEdit && !editing" type="primary" size="small" @click="startEdit">编辑</el-button>
<template v-else-if="canEdit && editing">
<el-button size="small" @click="cancelEdit">取消</el-button>
<el-button type="primary" size="small" :loading="saving" @click="saveEdit">保存</el-button>
</template>
</div>
</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">基本信息</div>
</div>
</template>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="受试者">
<el-select v-if="editing" v-model="form.subject_id" placeholder="可留空" filterable class="inline-input">
<el-option v-for="s in subjectOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<span v-else>{{ subjectName(ae.subject_id) }}</span>
</el-descriptions-item>
<el-descriptions-item label="事件描述">
<el-input v-if="editing" v-model="form.term" class="inline-input" />
<span v-else>{{ ae.term || "" }}</span>
</el-descriptions-item>
<el-descriptions-item label="SAE">
<el-select v-if="editing" v-model="form.seriousness" placeholder="请选择" class="inline-input">
<el-option label="是" value="SERIOUS" />
<el-option label="否" value="NON_SERIOUS" />
</el-select>
<span v-else>{{ seriousnessLabel(ae.seriousness) }}</span>
</el-descriptions-item>
<el-descriptions-item label="严重程度">
<el-select v-if="editing" v-model="form.severity" placeholder="请选择" class="inline-input">
<el-option v-for="s in severities" :key="s" :label="severityLabel(s)" :value="s" />
</el-select>
<span v-else>{{ severityLabel(ae.severity) }}</span>
</el-descriptions-item>
<el-descriptions-item label="发生日期">
<el-date-picker
v-if="editing"
v-model="form.onset_date"
type="date"
value-format="YYYY-MM-DD"
class="inline-input"
:disabled-date="disableFutureDate"
/>
<span v-else>{{ ae.onset_date || "" }}</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-select v-if="editing" v-model="form.status" placeholder="请选择" class="inline-input">
<el-option v-for="s in statusOptions" :key="s" :label="stateLabel(s)" :value="s" />
</el-select>
<el-tag v-else :type="stateColor(aeState)" effect="plain">{{ stateLabel(aeState) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">
<el-input v-if="editing" v-model="form.description" type="textarea" class="inline-input" />
<span v-else>{{ ae.description || "" }}</span>
</el-descriptions-item>
<el-descriptions-item label="转归结局" :span="2">
<el-select v-if="editing" v-model="form.outcome" placeholder="可留空" clearable class="inline-input">
<el-option v-for="o in outcomes" :key="o" :label="o" :value="o" />
</el-select>
<span v-else>{{ ae.outcome || "" }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">协作记录</div>
</div>
</template>
<el-tabs class="detail-tabs">
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="aes" :entity-id="ae.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="aes"
:entity-id="ae.id"
:show-uploader="true"
/>
</el-tab-pane>
<el-tab-pane label="变更记录">
<el-timeline v-if="auditLogs.length">
<el-timeline-item v-for="log in auditLogs" :key="log.id" :timestamp="displayDateTime(log.created_at)">
<div class="audit-item">
<div class="audit-meta">
<strong>{{ displayUser(log.operator_id, { members: memberMap }) }}</strong>
<span class="audit-action">{{ log.action }}</span>
</div>
<el-button
v-if="canDeleteAudit"
type="danger"
text
size="small"
@click="onDeleteAuditLog(log.id)"
>
删除
</el-button>
</div>
<div v-if="log._detail" class="audit-detail-grid">
<div class="audit-detail-row">
<span class="audit-detail-label">事件</span>
<span class="audit-detail-value">{{ formatAuditField(log._detail, "event") }}</span>
</div>
<div class="audit-detail-row">
<span class="audit-detail-label">发生日期</span>
<span class="audit-detail-value">
{{ formatAuditField(log._detail, "onset_date") }}
</span>
</div>
<div class="audit-detail-row">
<span class="audit-detail-label">严重程度</span>
<span class="audit-detail-value">
{{ formatAuditField(log._detail, "severity", severityLabel) }}
</span>
</div>
<div class="audit-detail-row">
<span class="audit-detail-label">状态</span>
<span class="audit-detail-value">
{{ formatAuditField(log._detail, "status", stateLabel) }}
</span>
</div>
<div class="audit-detail-row">
<span class="audit-detail-label">描述</span>
<span class="audit-detail-value">{{ formatAuditField(log._detail, "description") }}</span>
</div>
</div>
<div v-else class="audit-detail">{{ log.detail || "—" }}</div>
</el-timeline-item>
</el-timeline>
<div v-else class="empty">暂无变更记录</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<div v-else class="ctms-page">
<StateLoading :rows="4" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchAes, updateAe } from "../api/aes";
import { fetchSubjects } from "../api/subjects";
import { deleteAuditLog, fetchAuditLogs } from "../api/auditLogs";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import StateLoading from "../components/StateLoading.vue";
import { usePermission } from "../utils/permission";
import { statusDict, getDictColor, getDictLabel } from "../dictionaries";
import { listMembers } from "../api/members";
import { displayDateTime, displayUser, getMemberDisplayName } from "../utils/display";
const route = useRoute();
const study = useStudyStore();
const auth = useAuthStore();
const ae = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const subjects = ref<any[]>([]);
const members = ref<any[]>([]);
const auditLogs = ref<any[]>([]);
const editing = ref(false);
const saving = ref(false);
const form = reactive({
subject_id: "",
term: "",
onset_date: "",
seriousness: "NON_SERIOUS",
severity: "G1",
status: "NEW",
description: "",
outcome: "",
});
const severities = ["G1", "G2", "G3", "G4", "G5"];
const statusOptions = ["FOLLOW_UP", "CLOSED"];
const outcomes = ["痊愈", "好转", "持续", "恶化", "死亡", "其他"];
const severityLabel = (v?: string | null) =>
({
G1: "I级",
G2: "II级",
G3: "III级",
G4: "IV级",
G5: "V级",
}[v || ""] || v || "—");
const seriousnessLabel = (v?: string | null) =>
({ SERIOUS: "是", NON_SERIOUS: "否" }[v || ""] || v || "—");
const { can } = usePermission();
const canEdit = computed(() => can("ae.create"));
const canDeleteAudit = computed(() => auth.user?.role === "ADMIN");
const aeState = computed(() => (ae.value?.status as string) || "NEW");
const stateLabel = (v?: string | null) =>
({ FOLLOW_UP: "随访中", CLOSED: "结束", NEW: "随访中" }[v || ""] || getDictLabel(statusDict, v || ""));
const stateColor = (v?: string | null) => getDictColor(statusDict, v || "") || "info";
const disableFutureDate = (time: Date) => time.getTime() > Date.now();
const parseAuditDetail = (detail: any) => {
if (!detail) return null;
if (typeof detail === "object") return detail;
if (typeof detail !== "string") return null;
try {
const parsed = JSON.parse(detail);
if (parsed && typeof parsed === "object") return parsed;
} catch {
return null;
}
return null;
};
const formatAuditValue = (value: any, formatter?: (val?: string | null) => string) => {
if (value === null || value === undefined || value === "") return "—";
return formatter ? formatter(value) : String(value);
};
const formatAuditField = (
detail: any,
key: string,
formatter?: (val?: string | null) => string
) => {
const before = detail?.before?.[key];
const after = detail?.after?.[key];
if (before !== undefined || after !== undefined) {
const beforeLabel = formatAuditValue(before, formatter);
const afterLabel = formatAuditValue(after, formatter);
if (beforeLabel === "—" && afterLabel === "—") return "—";
if (beforeLabel === afterLabel) return afterLabel;
if (beforeLabel === "—") return afterLabel;
if (afterLabel === "—") return beforeLabel;
return `${beforeLabel}${afterLabel}`;
}
return formatAuditValue(detail?.[key], formatter);
};
const enrichOverdue = (item: any) => {
if (!item) return item;
return {
...item,
is_overdue:
item.is_overdue ??
(item.report_due_date && item.status !== "CLOSED" && new Date() > new Date(item.report_due_date)),
};
};
const load = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchAes(study.currentStudy.id, { skip: 0, limit: 500 });
const list = data.items || data || [];
ae.value = enrichOverdue(list.find((item: any) => item.id === route.params.aeId));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "AE 加载失败");
}
};
const loadSubjects = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
subjects.value = data.items || data || [];
} catch {
subjects.value = [];
}
};
const subjectLabel = computed(() => {
const map = subjects.value.reduce<Record<string, string>>((acc, cur) => {
if (cur?.id) acc[cur.id] = cur.subject_no || cur.id;
return acc;
}, {});
return map;
});
const subjectOptions = computed(() =>
subjects.value.map((s: any) => ({ value: s.id, label: s.subject_no || s.id }))
);
const subjectName = (id?: string | null) => {
if (!id) return "—";
return subjectLabel.value[id] || id;
};
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
return acc;
}, {})
);
const loadMembers = async () => {
if (!study.currentStudy) return;
try {
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const loadAuditLogs = async () => {
if (!study.currentStudy || !ae.value) return;
try {
const { data } = await fetchAuditLogs(study.currentStudy.id, {
entity_type: "ae",
entity_id: ae.value.id,
limit: 200,
});
const logs = Array.isArray(data) ? data : data.items || [];
auditLogs.value = logs.map((log: any) => ({
...log,
_detail: parseAuditDetail(log.detail),
}));
} catch {
auditLogs.value = [];
}
};
const onDeleteAuditLog = async (logId: string) => {
if (!study.currentStudy) return;
try {
await ElMessageBox.confirm("确认删除该变更记录?", "提示", { type: "warning" });
} catch {
return;
}
try {
await deleteAuditLog(study.currentStudy.id, logId);
auditLogs.value = auditLogs.value.filter((log) => log.id !== logId);
ElMessage.success("已删除");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
const syncForm = () => {
if (!ae.value) return;
form.subject_id = ae.value.subject_id || "";
form.term = ae.value.term || "";
form.onset_date = ae.value.onset_date || "";
form.seriousness = ae.value.seriousness || "NON_SERIOUS";
form.severity = ae.value.severity || "G1";
form.status = statusOptions.includes(ae.value.status) ? ae.value.status : "FOLLOW_UP";
form.description = ae.value.description || "";
form.outcome = ae.value.outcome || "";
};
const startEdit = () => {
syncForm();
editing.value = true;
};
const cancelEdit = () => {
editing.value = false;
syncForm();
};
const saveEdit = async () => {
if (!study.currentStudy || !ae.value) return;
if (!form.term.trim()) {
ElMessage.warning("请输入事件描述");
return;
}
if (!form.onset_date) {
ElMessage.warning("请选择发生日期");
return;
}
if (form.onset_date && new Date(form.onset_date) > new Date()) {
ElMessage.warning("发生日期不得晚于当前日期");
return;
}
if (!form.seriousness) {
ElMessage.warning("请选择 SAE");
return;
}
if (!form.severity) {
ElMessage.warning("请选择严重程度");
return;
}
if (!form.status) {
ElMessage.warning("请选择状态");
return;
}
if (form.status === "CLOSED" && !form.outcome) {
ElMessage.warning("状态为结束时,请选择转归结局");
return;
}
saving.value = true;
try {
const payload: Record<string, any> = {};
Object.entries(form).forEach(([k, v]) => {
if (v !== "" && v !== null && v !== undefined) {
payload[k] = v;
}
});
if (form.outcome === "" || form.outcome === null || form.outcome === undefined) {
payload.outcome = null;
}
const resp = await updateAe(study.currentStudy.id, ae.value.id, payload);
ae.value = enrichOverdue(resp.data);
ElMessage.success("已保存");
editing.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
saving.value = false;
}
};
watch(
() => ae.value,
() => {
if (!editing.value) {
syncForm();
}
loadAuditLogs();
}
);
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSubjects(), loadMembers()]);
load();
});
</script>
<style scoped>
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
.detail-tabs {
margin-top: -8px;
}
.audit-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.audit-meta {
display: flex;
align-items: center;
gap: 8px;
}
.audit-action {
font-size: 12px;
color: #64748b;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 999px;
}
.audit-detail {
margin-top: 4px;
color: #475569;
font-size: 12px;
}
.audit-detail-grid {
margin-top: 6px;
display: grid;
gap: 6px;
font-size: 12px;
}
.audit-detail-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px;
align-items: start;
}
.audit-detail-label {
color: #94a3b8;
}
.audit-detail-value {
color: #475569;
word-break: break-word;
}
.empty {
color: #94a3b8;
font-size: 12px;
}
.inline-input {
width: 100%;
}
</style>
-222
View File
@@ -1,222 +0,0 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">不良事件</h1>
</div>
<div class="ctms-page-actions">
<el-button type="primary" v-if="canCreate" @click="showForm = true">新建不良事件</el-button>
</div>
</div>
<el-card class="ctms-filter-card">
<div class="ctms-filter-row">
<div class="ctms-filter-item">
<span class="ctms-filter-label">状态</span>
<el-select v-model="filters.status" placeholder="全部" clearable @change="loadAes">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">SAE</span>
<el-select v-model="filters.seriousness" placeholder="全部" clearable @change="loadAes">
<el-option v-for="s in seriousnessOptions" :key="s" :label="seriousnessLabel(s)" :value="s" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">仅逾期</span>
<el-switch v-model="filters.overdue" @change="loadAes" />
</div>
<div class="ctms-filter-spacer" />
</div>
</el-card>
<el-card class="ctms-table-card">
<el-table :data="aes" v-loading="loading" class="ctms-table" style="width: 100%" @row-click="goDetail">
<el-table-column prop="term" label="事件" />
<el-table-column label="受试者" width="200">
<template #default="scope">
{{ subjectMap[scope.row.subject_id] || scope.row.subject_id || "-" }}
</template>
</el-table-column>
<el-table-column label="中心" width="200">
<template #default="scope">
{{ siteName(subjectSiteMap[scope.row.subject_id]) }}
</template>
</el-table-column>
<el-table-column prop="seriousness" label="SAE" width="120">
<template #default="scope">
{{ seriousnessLabel(scope.row.seriousness) }}
</template>
</el-table-column>
<el-table-column prop="severity" label="严重程度" width="140">
<template #default="scope">
{{ severityLabel(scope.row.severity) }}
</template>
</el-table-column>
<el-table-column prop="onset_date" label="发生日期" width="140" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="ctms-pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<AeForm v-model="showForm" :subjects="subjects" @success="loadAes" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchAes } from "../api/aes";
import { fetchSubjects } from "../api/subjects";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import AeForm from "../components/AeForm.vue";
import { aeSeriousnessDict, aeSeverityDict, getDictLabel } from "../dictionaries";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const aes = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const subjects = ref<any[]>([]);
const sites = ref<any[]>([]);
const subjectMap = computed(() =>
subjects.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.subject_no || cur.id;
return acc;
}, {})
);
const subjectSiteMap = computed(() =>
subjects.value.reduce<Record<string, string>>((acc, cur) => {
if (cur?.id) acc[cur.id] = cur.site_id || "";
return acc;
}, {})
);
const siteMap = computed(() =>
sites.value.reduce<Record<string, string>>((acc, cur) => {
if (cur?.id) acc[cur.id] = cur.name || cur.id;
return acc;
}, {})
);
const statuses = ["FOLLOW_UP", "CLOSED"];
const seriousnessOptions = ["SERIOUS", "NON_SERIOUS"];
const filters = ref({
status: "",
seriousness: "",
overdue: false,
});
const showForm = ref(false);
const canCreate = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "CRA" || role === "PV";
});
const loadAes = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
if (filters.value.status) params.status_filter = filters.value.status;
if (filters.value.seriousness) params.seriousness = filters.value.seriousness;
if (filters.value.overdue) params.overdue = true;
const { data } = await fetchAes(study.currentStudy.id, params);
const mapOverdue = (list: any[]) =>
list.map((item) => ({
...item,
is_overdue:
item.is_overdue ??
(item.report_due_date && item.status !== "CLOSED" && new Date() > new Date(item.report_due_date)),
}));
if (Array.isArray(data)) {
aes.value = mapOverdue(data);
total.value = data.length;
} else {
const items = data.items || [];
aes.value = mapOverdue(items);
total.value = data.total || aes.value.length;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "AE 加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
loadAes();
};
const loadSubjects = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
subjects.value = data.items || data || [];
} catch {
// ignore
}
};
const loadSites = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSites(study.currentStudy.id, { skip: 0, limit: 500 });
sites.value = data.items || data || [];
} catch {
sites.value = [];
}
};
const goDetail = (row: any) => {
router.push(`/study/aes/${row.id}`);
};
const siteName = (id?: string) => {
if (!id) return "-";
return siteMap.value[id] || id;
};
const statusLabel = (v: string) =>
({
FOLLOW_UP: "随访中",
CLOSED: "结束",
NEW: "随访中",
}[v] || v);
const seriousnessLabel = (v: string) => getDictLabel(aeSeriousnessDict, v) || v;
const severityLabel = (v: string) => getDictLabel(aeSeverityDict, v) || v;
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSubjects(), loadSites()]);
loadAes();
});
</script>
<style scoped>
</style>
-314
View File
@@ -1,314 +0,0 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">数据问题</h1>
</div>
<div class="ctms-page-actions">
<el-button type="primary" v-if="canEdit" @click="showForm = true">新建数据问题</el-button>
</div>
</div>
<el-card class="ctms-filter-card">
<div class="ctms-filter-row">
<div class="ctms-filter-item">
<span class="ctms-filter-label">状态</span>
<el-select v-model="filters.status" placeholder="全部" clearable @change="loadQueries">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">负责人</span>
<el-select v-model="filters.assigned_to" placeholder="负责人" clearable filterable @change="loadQueries">
<el-option v-for="m in memberOptions" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">仅逾期</span>
<el-switch v-model="filters.overdue" @change="loadQueries" />
</div>
<div class="ctms-filter-spacer" />
</div>
</el-card>
<el-card class="ctms-table-card">
<el-table :data="queries" v-loading="loading" class="ctms-table" style="width: 100%" @row-click="goDetail">
<el-table-column prop="title" label="标题" />
<el-table-column label="受试者">
<template #default="scope">
{{ subjectMap[scope.row.subject_id] || scope.row.subject_id || "-" }}
</template>
</el-table-column>
<el-table-column prop="category" label="类别" width="140">
<template #default="scope">
{{ categoryLabel(scope.row.category) }}
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="120">
<template #default="scope">
{{ priorityLabel(scope.row.priority) }}
</template>
</el-table-column>
<el-table-column prop="due_date" label="截止日期" width="140" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="right">
<template #default="scope">
<el-button
v-if="showResolve(scope.row)"
type="primary"
size="small"
@click.stop="onResolve(scope.row)"
>
标记已解决
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="ctms-pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<DataQueryForm v-model="showForm" :subjects="subjects" @success="loadQueries" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchDataQueries, updateDataQuery } from "../api/dataQueries";
import { fetchSubjects } from "../api/subjects";
import { listMembers } from "../api/members";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import DataQueryForm from "../components/DataQueryForm.vue";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
import { getAvailableActions, type StateMachine } from "../state-machine";
import { getMemberDisplayName } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const queries = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const subjects = ref<any[]>([]);
const members = ref<any[]>([]);
const subjectMap = computed(() =>
subjects.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.subject_no || cur.id;
return acc;
}, {})
);
const memberOptions = computed(() =>
members.value
.map((m: any) => ({
value: m.user_id,
label: getMemberDisplayName(m) || m.user_id,
}))
.filter((m: any) => m.value)
);
const statuses = ["OPEN", "IN_PROGRESS", "ANSWERED", "CLOSED"];
const categories = ["MISSING", "INCONSISTENT", "OUT_OF_RANGE", "OTHER"];
const priorities = ["LOW", "MEDIUM", "HIGH"];
const dataQueryMachine: StateMachine = {
states: {
OPEN: { value: "OPEN", label: "待处理" },
IN_PROGRESS: { value: "IN_PROGRESS", label: "处理中" },
ANSWERED: { value: "ANSWERED", label: "已回复" },
CLOSED: { value: "CLOSED", label: "已关闭", isTerminal: true },
},
actions: {
resolve: {
key: "resolve",
label: "标记已解决",
from: ["OPEN", "IN_PROGRESS", "ANSWERED"],
to: "CLOSED",
confirm: true,
confirmText: "确认标记为已解决?",
},
},
};
const filters = ref({
status: "",
overdue: false,
assigned_to: "",
});
const showForm = ref(false);
const canEdit = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "CRA";
});
const loadQueries = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
if (filters.value.status) params.status_filter = filters.value.status;
if (filters.value.assigned_to) params.assigned_to = filters.value.assigned_to;
if (filters.value.overdue) params.overdue = true;
const { data } = await fetchDataQueries(study.currentStudy.id, params);
const markOverdue = (list: any[]) =>
list.map((item) => ({
...item,
is_overdue:
item.is_overdue ?? (item.due_date && item.status !== "CLOSED" && new Date() > new Date(item.due_date)),
}));
if (Array.isArray(data)) {
queries.value = markOverdue(data);
total.value = data.length;
} else {
const list = data.items || [];
queries.value = markOverdue(list);
total.value = data.total || queries.value.length;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "数据问题加载失败");
} finally {
loading.value = false;
}
};
const loadSubjects = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
subjects.value = data.items || data || [];
} catch {
subjects.value = [];
}
};
const loadMembers = async () => {
if (!study.currentStudy) return;
try {
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
members.value = Array.isArray(data) ? data : (data as any).items || [];
} catch {
members.value = [];
}
};
const onPageChange = (p: number) => {
page.value = p;
loadQueries();
};
const goDetail = (row: any) => {
router.push(`/study/data-queries/${row.id}`);
};
const statusLabel = (v: string) =>
({
OPEN: "待处理",
IN_PROGRESS: "处理中",
ANSWERED: "已回复",
CLOSED: "已关闭",
}[v] || v);
const categoryLabel = (v: string) =>
({
MISSING: "缺失",
INCONSISTENT: "不一致",
OUT_OF_RANGE: "超范围",
OTHER: "其他",
}[v] || v);
const priorityLabel = (v: string) =>
({
LOW: "低",
MEDIUM: "中",
HIGH: "高",
}[v] || v);
const showResolve = (row: any) => {
const actions = getAvailableActions(dataQueryMachine, row.status).filter((a) => a.key === "resolve");
if (!actions.length) return false;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "dataquery.edit",
stateMachine: dataQueryMachine,
currentState: row.status,
actionKey: "resolve",
});
return decision.allowed;
};
const onResolve = async (row: any) => {
if (!study.currentStudy) return;
const action = getAvailableActions(dataQueryMachine, row.status).find((a) => a.key === "resolve");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "dataquery.edit",
stateMachine: dataQueryMachine,
currentState: row.status,
actionKey: "resolve",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.title,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action?.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认操作?", "提示").catch(() => null);
if (!ok) return;
}
try {
await updateDataQuery(study.currentStudy.id, row.id, { status: action?.to || "CLOSED" });
ElMessage.success("已标记");
logAudit("QUERY_RESOLVED", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "CLOSED" },
severity: "normal",
});
loadQueries();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
logAudit("QUERY_RESOLVED", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "CLOSED" },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSubjects(), loadMembers()]);
loadQueries();
});
</script>
<style scoped>
</style>
-147
View File
@@ -1,147 +0,0 @@
<template>
<div class="ctms-page" v-if="query">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">数据问题</h1>
</div>
<div class="ctms-page-actions">
<el-button v-if="canEdit" type="primary" size="small" @click="nextStatus">流转状态</el-button>
</div>
</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">基础信息</div>
</div>
</template>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="受试者">
{{ subjectMap[query.subject_id] || query.subject_id || "-" }}
</el-descriptions-item>
<el-descriptions-item label="类别">{{ query.category }}</el-descriptions-item>
<el-descriptions-item label="优先级">{{ query.priority }}</el-descriptions-item>
<el-descriptions-item label="截止日期">{{ query.due_date }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="query.is_overdue ? 'danger' : 'info'" effect="plain">{{ query.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述">{{ query.description }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">协作记录</div>
</div>
</template>
<el-tabs class="detail-tabs">
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="data-queries" :entity-id="query.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="data-queries"
:entity-id="query.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<div v-else class="ctms-page">
<StateLoading :rows="4" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchDataQueries, updateDataQuery } from "../api/dataQueries";
import { fetchSubjects } from "../api/subjects";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import { usePermission } from "../utils/permission";
import StateLoading from "../components/StateLoading.vue";
const route = useRoute();
const study = useStudyStore();
const auth = useAuthStore();
const query = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const subjects = ref<any[]>([]);
const subjectMap = computed(() =>
subjects.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.subject_no || cur.id;
return acc;
}, {})
);
const { can } = usePermission();
const canEdit = computed(() => can("dataquery.edit"));
const enrichOverdue = (item: any) => {
if (!item) return item;
return {
...item,
is_overdue:
item.is_overdue ?? (item.due_date && item.status !== "CLOSED" && new Date() > new Date(item.due_date)),
};
};
const load = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchDataQueries(study.currentStudy.id, { skip: 0, limit: 500 });
const list = data.items || data || [];
query.value = enrichOverdue(list.find((i: any) => i.id === route.params.queryId));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "数据问题加载失败");
}
};
const nextStatus = async () => {
if (!query.value) return;
const flow = ["OPEN", "IN_PROGRESS", "ANSWERED", "CLOSED"];
const idx = flow.indexOf(query.value.status);
const next = flow[Math.min(idx + 1, flow.length - 1)];
if (next === query.value.status) {
ElMessage.info("已是最终状态");
return;
}
await ElMessageBox.confirm(`确认将状态流转为 ${next}`, "提示");
try {
const resp = await updateDataQuery(studyId.value, query.value.id, { status: next });
query.value = enrichOverdue(resp.data);
ElMessage.success("状态已更新");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
}
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
if (study.currentStudy) {
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
subjects.value = data.items || data || [];
}
load();
});
</script>
<style scoped>
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
.detail-tabs {
margin-top: -8px;
}
</style>
+2 -2
View File
@@ -21,7 +21,7 @@
<el-switch v-model="onlyActive" active-text="仅启用" @change="loadFaqs" />
<div class="spacer" />
<PermissionAction action="faq.create">
<el-button type="primary" @click="openForm()">新建 FAQ</el-button>
<el-button type="primary" @click="openForm()">新建咨询</el-button>
</PermissionAction>
</div>
</el-card>
@@ -111,7 +111,7 @@ const loadFaqs = async () => {
total.value = data.total || 0;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "FAQ 加载失败");
ElMessage.error(e?.response?.data?.message || "咨询加载失败");
} finally {
loading.value = false;
}
-360
View File
@@ -1,360 +0,0 @@
<template>
<div class="page">
<FinanceSummary :summary="summary" class="mb-12" />
<el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadList">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
<el-select v-model="filters.category" placeholder="类别" clearable @change="loadList">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
<el-date-picker
v-model="filters.date_from"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
@change="loadList"
/>
<el-date-picker
v-model="filters.date_to"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="loadList"
/>
<div class="spacer" />
<PermissionAction action="finance.create">
<el-button type="primary" @click="showForm = true">新建费用</el-button>
</PermissionAction>
</div>
</el-card>
<el-card>
<el-table :data="items" v-loading="loading" @row-click="goDetail" style="width: 100%">
<el-table-column prop="title" label="标题" min-width="160" />
<el-table-column prop="category" label="类别" width="140">
<template #default="scope">
{{ categoryLabel(scope.row.category) }}
</template>
</el-table-column>
<el-table-column prop="amount" label="金额" width="140" align="right">
<template #default="scope">
{{ formatAmount(scope.row.amount) }}
</template>
</el-table-column>
<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="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="submitted_at" label="提交时间" width="180">
<template #default="scope">{{ displayDateTime(scope.row.submitted_at) }}</template>
</el-table-column>
<el-table-column prop="approved_at" label="审批时间" width="180">
<template #default="scope">{{ displayDateTime(scope.row.approved_at) }}</template>
</el-table-column>
<el-table-column prop="paid_at" label="支付时间" width="180">
<template #default="scope">{{ displayDateTime(scope.row.paid_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="scope">
<el-button
v-if="showAction(scope.row, 'approve')"
type="primary"
size="small"
@click.stop="onApprove(scope.row)"
>
审批
</el-button>
<el-button
v-if="showAction(scope.row, 'reject')"
type="danger"
size="small"
@click.stop="onReject(scope.row)"
>
驳回
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<FinanceForm v-model="showForm" @success="loadList" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import FinanceSummary from "../components/FinanceSummary.vue";
import FinanceForm from "../components/FinanceForm.vue";
import { fetchFinanceItems, fetchFinanceSummary, changeFinanceStatus } from "../api/finance";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import { financeMachine, getAvailableActions } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
import { displayDate, displayDateTime } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const items = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const loading = ref(false);
const summary = ref<any>(null);
const filters = ref({
status: "",
category: "",
date_from: "",
date_to: "",
});
const statuses = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"];
const categories = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"];
const showForm = ref(false);
const { can } = usePermission();
const canCreate = computed(() => can("finance.create"));
const permissionMap: Record<string, string> = {
approve: "finance.approve",
reject: "finance.approve",
};
const formatAmount = (num?: number | string) => {
const n = Number(num);
return Number.isFinite(n) ? n.toFixed(2) : "0.00";
};
const statusTag = (status?: string) => {
switch (status) {
case "DRAFT":
return "info";
case "SUBMITTED":
return "warning";
case "APPROVED":
return "success";
case "REJECTED":
return "danger";
case "PAID":
return "success";
default:
return "info";
}
};
const statusLabel = (status?: string) =>
({
DRAFT: "草稿",
SUBMITTED: "已提交",
APPROVED: "已审批",
REJECTED: "已驳回",
PAID: "已支付",
}[status || ""] || status || "");
const categoryLabel = (c?: string) =>
({
SITE_FEE: "中心费用",
SUBJECT_STIPEND: "受试者补偿",
TRAVEL: "差旅",
OTHER: "其他",
}[c || ""] || c || "");
const showAction = (row: any, key: string) => {
const actions = getAvailableActions(financeMachine, row.status).filter((a) => a.key === key);
if (!actions.length) return false;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap[key],
stateMachine: financeMachine,
currentState: row.status,
actionKey: key,
});
return decision.allowed;
};
const doChange = async (row: any, status: string, reason?: string) => {
if (!study.currentStudy) return false;
try {
await changeFinanceStatus(study.currentStudy.id, row.id, { status, reject_reason: reason });
return true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
return false;
}
};
const onApprove = async (row: any) => {
const action = getAvailableActions(financeMachine, row.status).find((a) => a.key === "approve");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap.approve,
stateMachine: financeMachine,
currentState: row.status,
actionKey: "approve",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.title,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action?.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认审批通过?", "提示").catch(() => null);
if (!ok) return;
}
const ok = await doChange(row, action?.to || "APPROVED");
logAudit("FINANCE_APPROVED", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "APPROVED" },
severity: ok ? "normal" : "warning",
});
if (ok) {
ElMessage.success("已审批");
loadList();
}
};
const onReject = async (row: any) => {
const action = getAvailableActions(financeMachine, row.status).find((a) => a.key === "reject");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap.reject,
stateMachine: financeMachine,
currentState: row.status,
actionKey: "reject",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.title,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action?.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认驳回?", "提示", {
type: action.danger ? "warning" : "info",
}).catch(() => null);
if (!ok) return;
}
const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回", {
confirmButtonText: "确定",
cancelButtonText: "取消",
}).catch(() => null);
if (!reason || !reason.value) return;
const ok = await doChange(row, action?.to || "REJECTED", reason.value);
logAudit("FINANCE_REJECTED", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "REJECTED" },
severity: ok ? "normal" : "warning",
reason: reason.value,
});
if (ok) {
ElMessage.success("已驳回");
loadList();
}
};
const loadSummary = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchFinanceSummary(study.currentStudy.id);
summary.value = Array.isArray(data) ? data[0] : data;
} catch (e: any) {
/* ignore summary errors */
}
};
const loadList = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
if (filters.value.status) params.status_filter = filters.value.status;
if (filters.value.category) params.category = filters.value.category;
if (filters.value.date_from) params.date_from = filters.value.date_from;
if (filters.value.date_to) params.date_to = filters.value.date_to;
const { data } = await fetchFinanceItems(study.currentStudy.id, params);
if (Array.isArray(data)) {
items.value = data;
total.value = data.length;
} else {
items.value = data.items || [];
total.value = data.total || 0;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "费用列表加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
loadList();
};
const goDetail = (row: any) => {
router.push(`/study/finance/${row.id}`);
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSummary();
await loadList();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
-163
View File
@@ -1,163 +0,0 @@
<template>
<div class="page">
<el-card v-loading="loading">
<div class="header">
<div>
<h3>{{ item?.title }}</h3>
<el-tag :type="stateColor(item?.status)">{{ stateLabel(item?.status) }}</el-tag>
</div>
<div class="header-actions" v-if="item">
<PermissionAction action="finance.create">
<el-button v-if="canEditDraft" size="small" @click="showEdit = true">编辑</el-button>
</PermissionAction>
<FinanceStatusActions :item="item" @success="loadItem" />
</div>
</div>
<el-descriptions :column="2" border class="mt-12" v-if="item">
<el-descriptions-item label="类别">{{ categoryLabel(item.category) }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ formatAmount(item.amount) }} {{ item.currency }}</el-descriptions-item>
<el-descriptions-item label="发生日期">{{ displayDate(item.occur_date) }}</el-descriptions-item>
<el-descriptions-item label="中心">{{ siteMap[item.site_id] || "-" }}</el-descriptions-item>
<el-descriptions-item label="受试者">{{ subjectMap[item.subject_id] || "-" }}</el-descriptions-item>
<el-descriptions-item label="状态时间">
提交: {{ displayDateTime(item.submitted_at) }} / 审批: {{ displayDateTime(item.approved_at) }} /
支付: {{ displayDateTime(item.paid_at) }}
</el-descriptions-item>
<el-descriptions-item label="驳回原因">{{ item.reject_reason || "-" }}</el-descriptions-item>
<el-descriptions-item label="描述">{{ item.description || "-" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="mt-12" v-if="item">
<AttachmentList
:study-id="study.currentStudy?.id || ''"
entity-type="finance"
:entity-id="item.id"
:show-uploader="true"
/>
</el-card>
<FinanceForm v-model="showEdit" :item="item" @success="loadItem" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import FinanceStatusActions from "../components/FinanceStatusActions.vue";
import FinanceForm from "../components/FinanceForm.vue";
import PermissionAction from "../components/PermissionAction.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import { fetchFinanceItem } from "../api/finance";
import { fetchSites } from "../api/sites";
import { fetchSubjects } from "../api/subjects";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import { usePermission } from "../utils/permission";
import { financeMachine, getAvailableActions } from "../state-machine";
import { financeStatusDict, getDictColor, getDictLabel } from "../dictionaries";
import { displayDate, displayDateTime } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
const route = useRoute();
const item = ref<any>(null);
const loading = ref(false);
const showEdit = ref(false);
const sites = ref<any[]>([]);
const subjects = ref<any[]>([]);
const { can } = usePermission();
const canEditDraft = computed(() => {
const actions = getAvailableActions(financeMachine, item.value?.status);
const submitAllowed = actions.some((a) => a.key === "submit");
return submitAllowed && can("finance.create");
});
const loadItem = async () => {
if (!study.currentStudy) return;
const id = route.params.itemId as string;
loading.value = true;
try {
const { data } = await fetchFinanceItem(study.currentStudy.id, id);
item.value = data;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const loadSites = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
sites.value = (data as any).items || data || [];
} catch {
sites.value = [];
}
};
const loadSubjects = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSubjects(study.currentStudy.id, { limit: 500 });
subjects.value = data.items || data || [];
} catch {
subjects.value = [];
}
};
const siteMap = computed(() =>
sites.value.reduce<Record<string, string>>((acc, cur) => {
if (cur?.id) acc[cur.id] = cur.name || cur.id;
return acc;
}, {})
);
const subjectMap = computed(() =>
subjects.value.reduce<Record<string, string>>((acc, cur) => {
if (cur?.id) acc[cur.id] = cur.subject_no || cur.id;
return acc;
}, {})
);
const stateLabel = (v?: string | null) => getDictLabel(financeStatusDict, v || "");
const stateColor = (v?: string | null) => getDictColor(financeStatusDict, v || "") || "info";
const categoryLabel = (c?: string | null) =>
({
SITE_FEE: "中心费用",
SUBJECT_STIPEND: "受试者补偿",
TRAVEL: "差旅",
OTHER: "其他",
VISIT_FEE: "访视补贴",
}[c || ""] || c || "-");
const formatAmount = (num?: number | string) => {
const n = Number(num);
return Number.isFinite(n) ? n.toFixed(2) : "0.00";
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSites(), loadSubjects()]);
await loadItem();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.mt-12 {
margin-top: 12px;
}
</style>
-31
View File
@@ -1,31 +0,0 @@
<template>
<div class="page">
<el-card>
<h2>药品管理</h2>
<div class="links">
<el-button type="primary" plain @click="go('/study/imp/inventory')">库存</el-button>
<el-button type="primary" plain @click="go('/study/imp/products')">产品</el-button>
<el-button type="primary" plain @click="go('/study/imp/batches')">批次</el-button>
<el-button type="primary" plain @click="go('/study/imp/transactions')">交易台账</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
const router = useRouter();
const go = (path: string) => router.push(path);
</script>
<style scoped>
.page {
padding: 16px;
}
.links {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
</style>
-161
View File
@@ -1,161 +0,0 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.product_id" placeholder="产品" clearable @change="load">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-select v-model="filters.status" placeholder="状态" clearable @change="load">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
<div class="spacer" />
<el-button type="primary" v-if="canEdit" @click="openForm()">新建批次</el-button>
</div>
</el-card>
<el-card>
<el-table :data="batches" v-loading="loading" style="width: 100%">
<el-table-column prop="batch_no" label="批号" />
<el-table-column prop="product_id" label="产品">
<template #default="scope">
{{ productMap[scope.row.product_id] || scope.row.product_id }}
</template>
</el-table-column>
<el-table-column prop="expiry_date" label="失效日期" width="140" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag>{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" v-if="canEdit">
<template #default="scope">
<el-button type="text" size="small" @click.stop="openForm(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<ImpBatchForm v-model="showForm" :batch="editing" :products="products" @success="load" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchImpProducts } from "../api/impProducts";
import { fetchImpBatches } from "../api/impBatches";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import ImpBatchForm from "../components/ImpBatchForm.vue";
const study = useStudyStore();
const auth = useAuthStore();
const products = ref<any[]>([]);
const batches = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const loading = ref(false);
const filters = ref({ product_id: "", status: "" });
const statuses = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"];
const showForm = ref(false);
const editing = ref<any | null>(null);
const canEdit = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "IMP";
});
const productMap = computed(() =>
products.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.name;
return acc;
}, {})
);
const loadProducts = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchImpProducts(study.currentStudy.id);
products.value = Array.isArray(data) ? data : data.items || [];
} catch {
/* ignore */
}
};
const load = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
if (filters.value.product_id) params.product_id = filters.value.product_id;
if (filters.value.status) params.status_filter = filters.value.status;
const { data } = await fetchImpBatches(study.currentStudy.id, params);
if (Array.isArray(data)) {
batches.value = data;
total.value = data.length;
} else {
batches.value = data.items || [];
total.value = data.total || 0;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const openForm = (row?: any) => {
editing.value = row || null;
showForm.value = true;
};
const onPageChange = (p: number) => {
page.value = p;
load();
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadProducts();
load();
});
const statusLabel = (v: string) =>
({
ACTIVE: "在库",
QUARANTINED: "隔离",
EXPIRED: "过期",
CLOSED: "关闭",
}[v] || v);
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.mb-12 {
margin-bottom: 12px;
}
.pagination {
margin-top: 12px;
text-align: right;
}
</style>
-146
View File
@@ -1,146 +0,0 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-select
v-model="filters.site_id"
placeholder="中心"
clearable
filterable
@change="load"
style="width: 220px"
>
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-select v-model="filters.product_id" placeholder="产品" clearable @change="load" filterable>
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<div class="spacer" />
<el-button @click="load">刷新</el-button>
</div>
</el-card>
<el-card>
<el-table :data="rows" v-loading="loading" style="width: 100%">
<el-table-column prop="site_id" label="中心">
<template #default="scope">{{ siteName(scope.row.site_id) }}</template>
</el-table-column>
<el-table-column prop="batch_id" label="批次">
<template #default="scope">{{ batchName(scope.row.batch_id) }}</template>
</el-table-column>
<el-table-column prop="quantity_on_hand" label="当前结余" width="120" align="right" />
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchImpInventory } from "../api/impInventory";
import { fetchImpProducts } from "../api/impProducts";
import { fetchImpBatches } from "../api/impBatches";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import { displayDateTime } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
const filters = ref({ site_id: "", product_id: "" });
const rows = ref<any[]>([]);
const loading = ref(false);
const products = ref<any[]>([]);
const batches = ref<any[]>([]);
const sites = ref<any[]>([]);
const loadProducts = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchImpProducts(study.currentStudy.id);
products.value = Array.isArray(data) ? data : data.items || [];
} catch {
/* ignore */
}
};
const loadBatches = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchImpBatches(study.currentStudy.id, { limit: 500 });
batches.value = Array.isArray(data) ? data : data.items || [];
} catch {
batches.value = [];
}
};
const loadSites = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
sites.value = (data as any).items || data || [];
} catch {
sites.value = [];
}
};
const load = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = {};
if (filters.value.site_id) params.site_id = filters.value.site_id;
if (filters.value.product_id) params.product_id = filters.value.product_id;
const { data } = await fetchImpInventory(study.currentStudy.id, params);
if (Array.isArray(data)) {
rows.value = data;
} else {
rows.value = data.items || [];
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "库存加载失败");
} finally {
loading.value = false;
}
};
const siteName = (id?: string) => {
if (!id) return "-";
const found = sites.value.find((s: any) => s.id === id);
return found?.name || id;
};
const batchName = (id?: string) => {
if (!id) return "-";
const found = batches.value.find((b: any) => b.id === id);
return found?.batch_no || found?.code || id;
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadProducts(), loadBatches(), loadSites()]);
load();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
-86
View File
@@ -1,86 +0,0 @@
<template>
<div class="page">
<div class="toolbar">
<el-button type="primary" v-if="canEdit" @click="openForm()">新建 IMP 产品</el-button>
</div>
<el-card>
<el-table :data="products" v-loading="loading" style="width: 100%">
<el-table-column prop="name" label="名称" />
<el-table-column prop="form" label="剂型" width="120" />
<el-table-column prop="strength" label="规格" width="120" />
<el-table-column prop="unit" label="单位" width="80" />
<el-table-column prop="is_active" label="启用" width="100">
<template #default="scope">
<el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" v-if="canEdit">
<template #default="scope">
<el-button type="text" size="small" @click.stop="openForm(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<ImpProductForm v-model="showForm" :product="editing" @success="load" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchImpProducts } from "../api/impProducts";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import ImpProductForm from "../components/ImpProductForm.vue";
const study = useStudyStore();
const auth = useAuthStore();
const products = ref<any[]>([]);
const loading = ref(false);
const showForm = ref(false);
const editing = ref<any | null>(null);
const canEdit = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "IMP";
});
const load = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const { data } = await fetchImpProducts(study.currentStudy.id);
if (Array.isArray(data)) {
products.value = data;
} else {
products.value = data.items || [];
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const openForm = (row?: any) => {
editing.value = row || null;
showForm.value = true;
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
load();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.toolbar {
margin-bottom: 12px;
}
</style>
-273
View File
@@ -1,273 +0,0 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-select
v-model="filters.site_id"
placeholder="中心"
clearable
filterable
style="width: 200px"
@change="load"
>
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-select
v-model="filters.batch_id"
placeholder="批次"
clearable
filterable
style="width: 220px"
@change="load"
>
<el-option v-for="b in batches" :key="b.id" :label="batchLabel(b)" :value="b.id" />
</el-select>
<el-select v-model="filters.tx_type" placeholder="交易类型" clearable @change="load" style="width: 160px">
<el-option v-for="t in txTypes" :key="t" :label="txTypeLabel(t)" :value="t" />
</el-select>
<el-date-picker
v-model="filters.date_from"
type="date"
placeholder="起始日期"
value-format="YYYY-MM-DD"
@change="load"
/>
<el-date-picker
v-model="filters.date_to"
type="date"
placeholder="截止日期"
value-format="YYYY-MM-DD"
@change="load"
/>
<div class="spacer" />
<PermissionAction action="imp.transaction">
<el-button type="primary" @click="showForm = true">新建交易</el-button>
</PermissionAction>
</div>
</el-card>
<el-card>
<el-table :data="txs" v-loading="loading" style="width: 100%">
<el-table-column prop="tx_date" label="日期" width="120">
<template #default="scope">{{ displayDate(scope.row.tx_date) }}</template>
</el-table-column>
<el-table-column prop="tx_type" label="类型" width="140">
<template #default="scope">
{{ txTypeLabel(scope.row.tx_type) }}
</template>
</el-table-column>
<el-table-column prop="site_id" label="中心" width="180">
<template #default="scope">{{ siteName(scope.row.site_id) }}</template>
</el-table-column>
<el-table-column prop="batch_id" label="批次" width="220">
<template #default="scope">{{ batchName(scope.row.batch_id) }}</template>
</el-table-column>
<el-table-column prop="subject_id" label="受试者" width="180">
<template #default="scope">{{ subjectName(scope.row.subject_id) }}</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="100" align="right" />
<el-table-column prop="reference" label="单号/备注" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button
v-if="canEdit"
type="primary"
size="small"
@click.stop="confirmTx(scope.row)"
>
确认
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<ImpTransactionForm v-model="showForm" :batches="batches" @success="load" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchImpTransactions } from "../api/impTransactions";
import { fetchImpBatches } from "../api/impBatches";
import { fetchSubjects } from "../api/subjects";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import ImpTransactionForm from "../components/ImpTransactionForm.vue";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
import { displayDate } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
const filters = ref({ site_id: "", batch_id: "", tx_type: "", date_from: "", date_to: "" });
const txTypes = ["RECEIPT", "DISPENSE", "RETURN", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"];
const txs = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const loading = ref(false);
const showForm = ref(false);
const batches = ref<any[]>([]);
const sites = ref<any[]>([]);
const subjects = ref<any[]>([]);
const { can } = usePermission();
const canEdit = computed(() => can("imp.transaction"));
const loadBatches = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchImpBatches(study.currentStudy.id, { limit: 200 });
batches.value = Array.isArray(data) ? data : data.items || [];
} catch {
/* ignore */
}
};
const loadSites = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
sites.value = (data as any).items || data || [];
} catch {
sites.value = [];
}
};
const loadSubjects = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSubjects(study.currentStudy.id, { limit: 1000 });
subjects.value = data.items || data || [];
} catch {
subjects.value = [];
}
};
const load = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
Object.entries(filters.value).forEach(([k, v]) => {
if (v) params[k] = v;
});
const { data } = await fetchImpTransactions(study.currentStudy.id, params);
if (Array.isArray(data)) {
txs.value = data;
total.value = data.length;
} else {
txs.value = data.items || [];
total.value = data.total || 0;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "交易加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
load();
};
const txTypeLabel = (t: string) =>
({
RECEIPT: "入库",
DISPENSE: "发放",
RETURN: "回收",
DESTROY: "销毁",
TRANSFER_IN: "调入",
TRANSFER_OUT: "调出",
}[t] || t);
const confirmTx = async (row: any) => {
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "imp.transaction",
actionKey: "confirm",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.reference,
reason: decision.reason,
severity: decision.severity,
});
return;
}
const ok = await ElMessageBox.confirm("确认该出入库记录?", "提示", { type: "info" }).catch(() => null);
if (!ok) return;
// 无需调用后端变更(无状态字段),仅记录审计并提示
logAudit("IMP_CONFIRMED", {
targetId: row.id,
targetName: row.reference,
severity: "normal",
});
ElMessage.success("已确认");
load();
};
const siteName = (id?: string) => {
if (!id) return "-";
const found = sites.value.find((s: any) => s.id === id);
return found?.name || id;
};
const batchLabel = (b: any) =>
`${b?.batch_no || b?.code || b?.id || "-"}` + (b?.product_name ? ` (${b.product_name})` : "");
const batchName = (id?: string) => {
if (!id) return "-";
const found = batches.value.find((b: any) => b.id === id);
return found ? batchLabel(found) : id;
};
const subjectName = (id?: string) => {
if (!id) return "-";
const found = subjects.value.find((s: any) => s.id === id);
return found?.subject_no || id;
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadBatches(), loadSites(), loadSubjects()]);
load();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.mb-12 {
margin-bottom: 12px;
}
.pagination {
margin-top: 12px;
text-align: right;
}
</style>
-122
View File
@@ -1,122 +0,0 @@
<template>
<div class="ctms-page" v-if="issue">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">风险与问题</h1>
</div>
<div class="ctms-page-actions">
<PermissionAction action="issue.close">
<el-button type="danger" size="small" link @click="closeIssue">关闭</el-button>
</PermissionAction>
</div>
</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">基础信息</div>
</div>
</template>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="类别">{{ issue.category }}</el-descriptions-item>
<el-descriptions-item label="等级">{{ issue.level }}</el-descriptions-item>
<el-descriptions-item label="截止日期">{{ issue.due_date }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(issue.status) }}</el-descriptions-item>
<el-descriptions-item label="描述">{{ issue.description }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">协作记录</div>
</div>
</template>
<el-tabs class="detail-tabs">
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="issues" :entity-id="issue.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="issues"
:entity-id="issue.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<div v-else class="ctms-page">
<StateLoading :rows="4" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchIssues, updateIssue } from "../api/issues";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import StateLoading from "../components/StateLoading.vue";
const route = useRoute();
const study = useStudyStore();
const auth = useAuthStore();
const issue = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const { can } = usePermission();
const canEdit = computed(() => can("issue.close"));
const load = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchIssues(study.currentStudy.id, { skip: 0, limit: 500 });
const list = data.items || data || [];
issue.value = list.find((item: any) => item.id === route.params.issueId);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "Issue 加载失败");
}
};
const closeIssue = async () => {
await ElMessageBox.confirm("确定要关闭该风险/问题吗?该操作不可撤销,请谨慎确认。", "提示");
if (!study.currentStudy || !issue.value) return;
try {
await updateIssue(study.currentStudy.id, issue.value.id, { status: "CLOSED" });
ElMessage.success("已关闭");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
}
};
const statusLabel = (v: string) =>
({
OPEN: "开放",
MITIGATING: "处理中",
CLOSED: "已关闭",
}[v] || v);
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
load();
});
</script>
<style scoped>
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
.detail-tabs {
margin-top: -8px;
}
</style>
-182
View File
@@ -1,182 +0,0 @@
<template>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">风险与问题</h1>
</div>
<div class="ctms-page-actions">
<el-button type="primary" v-if="canCreate" @click="showForm = true">新建风险/问题</el-button>
</div>
</div>
<el-card class="ctms-filter-card">
<div class="ctms-filter-row">
<div class="ctms-filter-item">
<span class="ctms-filter-label">状态</span>
<el-select v-model="filters.status" placeholder="全部" clearable @change="loadIssues">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">等级</span>
<el-select v-model="filters.level" placeholder="全部" clearable @change="loadIssues">
<el-option v-for="l in levels" :key="l" :label="levelLabel(l)" :value="l" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">类别</span>
<el-select v-model="filters.category" placeholder="全部" clearable @change="loadIssues">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">仅逾期</span>
<el-switch v-model="filters.overdue" @change="loadIssues" />
</div>
<div class="ctms-filter-spacer" />
</div>
</el-card>
<el-card class="ctms-table-card">
<el-table :data="issues" v-loading="loading" class="ctms-table" style="width: 100%" @row-click="goDetail">
<el-table-column prop="title" label="标题" />
<el-table-column prop="level" label="等级" width="120">
<template #default="scope">
{{ levelLabel(scope.row.level) }}
</template>
</el-table-column>
<el-table-column prop="category" label="类别" width="140">
<template #default="scope">
{{ categoryLabel(scope.row.category) }}
</template>
</el-table-column>
<el-table-column prop="due_date" label="截止日期" width="140" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="ctms-pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<IssueForm v-model="showForm" @success="loadIssues" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchIssues } from "../api/issues";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import IssueForm from "../components/IssueForm.vue";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const issues = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const statuses = ["OPEN", "MITIGATING", "CLOSED"];
const levels = ["LOW", "MEDIUM", "HIGH", "CRITICAL"];
const categories = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"];
const filters = ref({
status: "",
level: "",
category: "",
overdue: false,
});
const showForm = ref(false);
const canCreate = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "PV";
});
const loadIssues = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
if (filters.value.status) params.status_filter = filters.value.status;
if (filters.value.level) params.level = filters.value.level;
if (filters.value.category) params.category = filters.value.category;
if (filters.value.overdue) params.overdue = true;
const { data } = await fetchIssues(study.currentStudy.id, params);
const mapOverdue = (list: any[]) =>
list.map((item) => ({
...item,
is_overdue:
item.is_overdue ?? (item.due_date && item.status !== "CLOSED" && new Date() > new Date(item.due_date)),
}));
if (Array.isArray(data)) {
issues.value = mapOverdue(data);
total.value = data.length;
} else {
const list = data.items || [];
issues.value = mapOverdue(list);
total.value = data.total || issues.value.length;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "Issue 加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
loadIssues();
};
const goDetail = (row: any) => {
router.push(`/study/issues/${row.id}`);
};
const statusLabel = (v: string) =>
({
OPEN: "开放",
MITIGATING: "处理中",
CLOSED: "已关闭",
}[v] || v);
const levelLabel = (v: string) =>
({
LOW: "低",
MEDIUM: "中",
HIGH: "高",
CRITICAL: "关键",
}[v] || v);
const categoryLabel = (v: string) =>
({
RISK: "风险",
ISSUE: "问题",
PROTOCOL_DEVIATION: "方案偏差",
}[v] || v);
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
loadIssues();
});
</script>
<style scoped>
</style>
-380
View File
@@ -1,380 +0,0 @@
<template>
<div class="ctms-page" v-if="milestone">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">节点详情</h1>
</div>
<div class="ctms-page-actions" v-if="canEdit">
<el-button v-if="!editing" type="primary" size="small" @click="startEdit">编辑</el-button>
<template v-else>
<el-button size="small" @click="cancelEdit">取消</el-button>
<el-button type="primary" size="small" :loading="saving" @click="saveEdit">保存</el-button>
</template>
</div>
</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">基础信息</div>
</div>
</template>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="名称">
<el-input v-if="editing" v-model="form.name" class="inline-input" />
<span v-else>{{ milestone.name || "" }}</span>
</el-descriptions-item>
<el-descriptions-item label="类型">
<el-select v-if="editing" v-model="form.type" placeholder="请选择" class="inline-input">
<el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span v-else>{{ typeLabel(milestone.type) }}</span>
</el-descriptions-item>
<el-descriptions-item label="中心">
<el-select v-if="editing" v-model="form.site_id" placeholder="请选择中心" filterable class="inline-input">
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
<span v-else>{{ siteName(milestone) }}</span>
</el-descriptions-item>
<el-descriptions-item label="负责人">
<el-select v-if="editing" v-model="form.owner_id" placeholder="请选择负责人" filterable class="inline-input">
<el-option v-for="m in memberOptions" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
<span v-else>{{ ownerName(milestone.owner_id) }}</span>
</el-descriptions-item>
<el-descriptions-item label="计划日期">
<el-date-picker
v-if="editing"
v-model="form.planned_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
:disabled-date="disablePastDates"
class="inline-input"
/>
<span v-else>{{ displayDate(milestone.planned_date) }}</span>
</el-descriptions-item>
<el-descriptions-item label="实际日期">
<el-date-picker
v-if="editing"
v-model="form.actual_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
:disabled-date="disablePastDates"
class="inline-input"
/>
<span v-else>{{ displayDate(milestone.actual_date) }}</span>
</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-select v-if="editing" v-model="form.status" placeholder="请选择" class="inline-input">
<el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-tag v-else :type="statusColor(milestone.status)" effect="plain">{{ statusLabel(milestone.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
<el-input v-if="editing" v-model="form.notes" type="textarea" class="inline-input" />
<span v-else>{{ milestone.notes || "" }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">协作记录</div>
</div>
</template>
<el-tabs class="detail-tabs">
<el-tab-pane label="评论">
<CommentList
v-if="milestone.id"
:study-id="studyId"
entity-type="milestones"
:entity-id="milestone.id"
:can-comment="true"
/>
</el-tab-pane>
<el-tab-pane label="附件">
<div class="tip">
<div>用于上传与本节点相关的证明文件如伦理批件会议纪要等仅用于证明该节点的完成情况</div>
<div class="sub-tip">项目通用文件请在项目详情查看中心级文件请在中心管理查看</div>
</div>
<AttachmentList
v-if="milestone.id"
:study-id="studyId"
entity-type="ethics_node"
:entity-id="milestone.id"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<div v-else class="ctms-page">
<StateLoading :rows="4" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchMilestones, updateMilestone } from "../api/milestones";
import { fetchAttachments } from "../api/attachments";
import { fetchSites } from "../api/sites";
import { listMembers } from "../api/members";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import StateLoading from "../components/StateLoading.vue";
import {
getMilestoneStatusColor,
getMilestoneStatusLabel,
getMilestoneTypeLabel,
milestoneStatusOptions,
milestoneTypeOptions,
} from "../dictionaries/milestone.dict";
import { displayDate, getMemberDisplayName, getUserDisplayName } from "../utils/display";
import { usePermission } from "../utils/permission";
const route = useRoute();
const study = useStudyStore();
const auth = useAuthStore();
const { can } = usePermission();
const milestone = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<any[]>([]);
const members = ref<any[]>([]);
const editing = ref(false);
const saving = ref(false);
const form = reactive({
name: "",
type: "",
site_id: "",
owner_id: "",
planned_date: "",
actual_date: "",
status: "NOT_STARTED",
notes: "",
});
const types = milestoneTypeOptions;
const statuses = milestoneStatusOptions;
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const disablePastDates = (date: Date) => date.getTime() < todayStart.getTime();
const loadMilestone = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchMilestones(study.currentStudy.id);
const list = data.items || data || [];
milestone.value = list.find((item: any) => item.id === route.params.milestoneId) || null;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "节点加载失败");
}
};
const loadSites = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
sites.value = (data as any).items || data || [];
} catch {
sites.value = [];
}
};
const loadMembers = async () => {
if (!study.currentStudy) return;
try {
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
const statusColor = (v: string) => getMilestoneStatusColor(v);
const canEdit = computed(() => can("milestone.create"));
const siteName = (row: any) => {
if (row?.site?.name) return row.site.name;
const id = row?.site_id || row?.center_id;
const name = id ? sites.value.find((s: any) => s.id === id)?.name : row?.site_name;
return name || "—";
};
const ownerName = (ownerId: string) => {
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
if (cur?.id && username) acc[cur.id] = username;
return acc;
}, {});
const owner = milestone.value?.owner;
const nameFromObject = getUserDisplayName(owner);
if (nameFromObject) return nameFromObject;
return memberMap[ownerId] || "—";
};
const memberOptions = computed(() => {
const memberList = members.value.filter((m: any) => m.is_active !== false);
const seen = new Set<string>();
return memberList
.map((m: any) => {
const user = m.user || {};
const label = user.full_name || user.display_name || user.username || user.email || m.username || m.email || "—";
return { value: m.user_id, label };
})
.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
});
const siteOptions = computed(() =>
sites.value.map((s: any) => ({
value: s.id,
label: s.name || "—",
}))
);
const syncForm = () => {
if (!milestone.value) return;
form.name = milestone.value.name || "";
form.type = milestone.value.type || "";
form.site_id = milestone.value.site_id || "";
form.owner_id = milestone.value.owner_id || "";
form.planned_date = milestone.value.planned_date || "";
form.actual_date = milestone.value.actual_date || "";
form.status = milestone.value.status || "NOT_STARTED";
form.notes = milestone.value.notes || "";
};
const startEdit = () => {
syncForm();
editing.value = true;
};
const cancelEdit = () => {
editing.value = false;
syncForm();
};
const validateForm = () => {
if (!form.name?.trim()) {
ElMessage.warning("请输入名称");
return false;
}
if (!form.type) {
ElMessage.warning("请选择类型");
return false;
}
if (!form.site_id) {
ElMessage.warning("请选择中心");
return false;
}
if (!form.owner_id) {
ElMessage.warning("请选择负责人");
return false;
}
if (!form.planned_date) {
ElMessage.warning("请选择计划日期");
return false;
}
return true;
};
const saveEdit = async () => {
if (!study.currentStudy || !milestone.value) return;
if (!validateForm()) return;
const parseDate = (value: string) => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
parsed.setHours(0, 0, 0, 0);
return parsed;
};
const planned = form.planned_date ? parseDate(form.planned_date) : null;
const actual = form.actual_date ? parseDate(form.actual_date) : null;
if (planned && planned.getTime() < todayStart.getTime()) {
ElMessage.warning("计划日期不得早于今天");
return;
}
if (actual && actual.getTime() < todayStart.getTime()) {
ElMessage.warning("实际日期不得早于今天");
return;
}
saving.value = true;
try {
const payload = {
...form,
actual_date: form.actual_date || null,
};
if (payload.actual_date) {
const { data } = await fetchAttachments(study.currentStudy.id, "ethics_node", milestone.value.id);
const items = (data as any).items || data || [];
if (!items.length) {
ElMessage.warning("请先上传伦理批件后再更新实际日期");
return;
}
payload.status = "DONE";
}
await updateMilestone(study.currentStudy.id, milestone.value.id, payload);
ElMessage.success("已保存");
await loadMilestone();
editing.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
saving.value = false;
}
};
watch(
() => milestone.value,
() => {
if (!editing.value) {
syncForm();
}
}
);
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSites(), loadMembers(), loadMilestone()]);
});
</script>
<style scoped>
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
.detail-tabs {
margin-top: -8px;
}
.inline-input {
width: 100%;
}
.tip {
margin-bottom: 8px;
color: var(--ctms-text-regular);
line-height: 1.4;
}
.sub-tip {
color: var(--ctms-text-secondary);
font-size: 12px;
}
</style>
-216
View File
@@ -1,216 +0,0 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="actions">
<h3>伦理与启动管理</h3>
<PermissionAction action="milestone.create">
<el-button type="primary" @click="openCreate">新建节点</el-button>
</PermissionAction>
</div>
</el-card>
<el-card>
<el-table :data="milestones" v-loading="loading" style="width: 100%" @row-click="goDetail">
<el-table-column prop="name" label="名称" min-width="160">
<template #default="scope">
<el-link type="primary" @click.stop="goDetail(scope.row)">{{ scope.row.name || "" }}</el-link>
</template>
</el-table-column>
<el-table-column label="类型" width="160">
<template #default="scope">
{{ typeLabel(scope.row.type) }}
</template>
</el-table-column>
<el-table-column label="中心" width="180">
<template #default="scope">
{{ siteName(scope.row) }}
</template>
</el-table-column>
<el-table-column label="负责人" width="160">
<template #default="scope">
{{ ownerName(scope.row.owner_id) }}
</template>
</el-table-column>
<el-table-column label="计划日期" width="140">
<template #default="scope">
{{ displayDate(scope.row.planned_date) }}
</template>
</el-table-column>
<el-table-column label="实际日期" width="140">
<template #default="scope">
{{ displayDate(scope.row.actual_date) }}
</template>
</el-table-column>
<el-table-column label="状态" width="140">
<template #default="scope">
<el-tag :type="statusColor(scope.row.status)">
{{ statusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button
v-if="canDelete"
type="danger"
link
size="small"
@click.stop="onDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<MilestoneForm
v-model="showForm"
:milestone="editingMilestone"
:sites="sites"
:members="members"
@success="loadMilestones"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchMilestones, deleteMilestone } from "../api/milestones";
import { fetchSites } from "../api/sites";
import { listMembers } from "../api/members";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import MilestoneForm from "../components/MilestoneForm.vue";
import {
getMilestoneStatusColor,
getMilestoneStatusLabel,
getMilestoneTypeLabel,
} from "../dictionaries/milestone.dict";
import { displayDate, displayUser, getMemberDisplayName } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const milestones = ref<any[]>([]);
const sites = ref<any[]>([]);
const members = ref<any[]>([]);
const loading = ref(false);
const showForm = ref(false);
const editingMilestone = ref<any | null>(null);
const canDelete = computed(() => auth.user?.role === "ADMIN" || !!study.currentStudyRole);
const loadMilestones = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const { data } = await fetchMilestones(study.currentStudy.id);
milestones.value = data.items || data;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "节点加载失败");
} finally {
loading.value = false;
}
};
const loadSites = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
sites.value = (data as any).items || data || [];
} catch {
sites.value = [];
}
};
const loadMembers = async () => {
if (!study.currentStudy) return;
try {
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const openCreate = () => {
editingMilestone.value = null;
showForm.value = true;
};
const onDelete = async (row: any) => {
if (!study.currentStudy || !row?.id) return;
const ok = await ElMessageBox.confirm("确认删除该节点?删除后不可恢复。", "提示", {
type: "warning",
confirmButtonText: "确认删除",
cancelButtonText: "取消",
}).catch(() => null);
if (!ok) return;
try {
await deleteMilestone(study.currentStudy.id, row.id);
ElMessage.success("已删除");
loadMilestones();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
onMounted(() => {
loadMilestones();
loadSites();
loadMembers();
});
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
const statusColor = (v: string) => getMilestoneStatusColor(v);
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
return acc;
}, {})
);
const siteName = (row: any) => {
if (row?.site?.name) return row.site.name;
const id = row?.site_id || row?.center_id;
const name = id ? sites.value.find((s: any) => s.id === id)?.name : row?.site_name;
return name || "—";
};
const ownerName = (ownerId: string) => {
const fromPayload = milestones.value.find((m) => m.owner_id === ownerId)?.owner;
return (
fromPayload?.full_name ||
fromPayload?.display_name ||
fromPayload?.username ||
fromPayload?.email ||
displayUser(ownerId, { members: memberMap.value })
);
};
const goDetail = (row: any) => {
if (!row?.id) return;
router.push({ name: "StudyMilestoneDetail", params: { milestoneId: row.id } });
};
</script>
<style scoped>
.page {
padding: 16px;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+5 -23
View File
@@ -3,7 +3,7 @@
<!-- 概览头部 -->
<div class="home-header">
<div class="header-content">
<h1 class="welcome-title">项目</h1>
<h1 class="welcome-title">项目</h1>
<p class="page-subtitle">当前项目运行状态与关键指标</p>
<p class="study-info">
<span class="study-name">{{ study.currentStudy.name }}</span>
@@ -38,16 +38,6 @@
:icon="Warning"
/>
</div>
<div class="stats-item">
<KpiCard
title="逾期数据问题"
:value="overdueQueries"
unit="项"
subtext="严禁滞留超过 48h"
:loading="loading.overdueQueries"
:icon="QuestionFilled"
/>
</div>
<div class="stats-item">
<KpiCard
title="费用总额"
@@ -61,7 +51,7 @@
<!-- 通知/警告 -->
<el-alert
v-if="overdueAes > 0 || overdueQueries > 0"
v-if="overdueAes > 0"
type="warning"
title="存在逾期未处理事项,请及时关注项目的合规性与进度。"
show-icon
@@ -92,10 +82,10 @@
import { computed, onMounted, ref, watch } from "vue";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchFinanceSummary, fetchOverdueAesCount, fetchOverdueQueriesCount, fetchProgress } from "../api/dashboard";
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue";
import { List, Warning, QuestionFilled, Money } from "@element-plus/icons-vue";
import { List, Warning, Money } from "@element-plus/icons-vue";
import StateEmpty from "../components/StateEmpty.vue";
const auth = useAuthStore();
@@ -104,13 +94,11 @@ const study = useStudyStore();
const progress = ref<any>(null);
const financeSummary = ref<any>(null);
const overdueAes = ref<number>(0);
const overdueQueries = ref<number>(0);
const loading = ref({
progress: false,
finance: false,
overdueAes: false,
overdueQueries: false,
});
const milestoneCompletionRate = computed(() => {
@@ -131,25 +119,20 @@ const loadData = async () => {
loading.value.progress = true;
loading.value.finance = true;
loading.value.overdueAes = true;
loading.value.overdueQueries = true;
try {
const [pRes, fRes, aesRes, dqRes] = await Promise.allSettled([
const [pRes, fRes, aesRes] = await Promise.allSettled([
fetchProgress(studyId),
fetchFinanceSummary(studyId),
fetchOverdueAesCount(studyId),
fetchOverdueQueriesCount(studyId),
]);
if (pRes.status === "fulfilled") progress.value = pRes.value.data;
if (fRes.status === "fulfilled") financeSummary.value = fRes.value.data;
if (aesRes.status === "fulfilled") overdueAes.value = aesRes.value.data?.total ?? 0;
if (dqRes.status === "fulfilled") overdueQueries.value = dqRes.value.data?.total ?? 0;
} finally {
loading.value.progress = false;
loading.value.finance = false;
loading.value.overdueAes = false;
loading.value.overdueQueries = false;
}
};
@@ -157,7 +140,6 @@ const resetData = () => {
progress.value = null;
financeSummary.value = null;
overdueAes.value = 0;
overdueQueries.value = 0;
};
onMounted(() => {
-335
View File
@@ -1,335 +0,0 @@
<template>
<div class="page-container" v-if="subject">
<!-- 头部信息卡片 -->
<div class="subject-header-card">
<div class="header-main">
<div class="subject-brand">
<div class="brand-avatar">{{ subject.subject_no?.charAt(0) }}</div>
<div class="brand-info">
<h1 class="subject-no">{{ subject.subject_no }}</h1>
<p class="site-info">{{ siteName(subject.site_id) }}</p>
<p class="meta-info">受试者详情与状态管理</p>
</div>
</div>
<div class="subject-status">
<span class="status-label">当前状态</span>
<el-tag :type="stateColor(subject.status)" effect="plain" class="status-tag">
{{ stateLabel(subject.status) }}
</el-tag>
</div>
</div>
<div class="header-actions">
<template v-for="action in availableActions" :key="action.key">
<PermissionAction :action="permissionMap[action.key]">
<el-button
:type="action.danger ? 'danger' : (action.primary ? 'primary' : 'default')"
:plain="!action.primary && !action.danger"
:link="action.danger"
size="default"
class="action-btn"
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</PermissionAction>
</template>
</div>
</div>
<!-- 访视记录区块 -->
<div class="info-section">
<div class="section-header">
<div class="section-title">
<el-icon><Calendar /></el-icon>
<span>访视记录</span>
</div>
<div class="section-desc">按访视节点维护受试者随访数据</div>
</div>
<div class="section-body">
<VisitTable :visits="visits" :subject-id="subject.id" :can-edit="canVisitEdit" :on-refresh="loadVisits" />
</div>
</div>
</div>
<div v-else class="loading-state">
<StateLoading :rows="5" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Calendar } from "@element-plus/icons-vue";
import { fetchSubjects, updateSubject } from "../api/subjects";
import { fetchVisits } from "../api/visits";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import VisitTable from "../components/VisitTable.vue";
import StateLoading from "../components/StateLoading.vue";
import { getAvailableActions, subjectMachine } from "../state-machine";
import type { ActionConfig } from "../state-machine";
import { statusDict, getDictLabel, getDictColor } from "../dictionaries";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const route = useRoute();
const study = useStudyStore();
const auth = useAuthStore();
const subject = ref<any | null>(null);
const visits = ref<any[]>([]);
const sites = ref<any[]>([]);
const { can } = usePermission();
const canVisitEdit = computed(() => can("subject.enroll"));
const permissionMap: Record<string, string> = {
enroll: "subject.enroll",
complete: "subject.complete",
drop: "subject.drop",
};
// 增加 primary 属性判断以便视觉区分
const availableActions = computed(() => {
const actions = getAvailableActions(subjectMachine, subject.value?.status);
return actions.map((a: any) => ({
...a,
primary: a.key === 'enroll' || a.key === 'complete'
}));
});
const stateLabel = (v?: string | null) => getDictLabel(statusDict, v || "");
const stateColor = (v?: string | null) => getDictColor(statusDict, v || "") || "info";
const loadSubject = async () => {
if (!study.currentStudy) return;
try {
const subjectId = route.params.subjectId as string;
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 1000 });
// 兼容處理 data 結構,解決類型檢查問題
const list = (data as any).items || (Array.isArray(data) ? data : []);
subject.value = list.find((s: any) => s.id === subjectId) || null;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "受试者加载失败");
}
};
const loadSites = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSites(study.currentStudy.id, { limit: 500 });
sites.value = (data as any).items || (Array.isArray(data) ? data : []);
} catch {
sites.value = [];
}
};
const siteName = (id?: string) => {
if (!id) return "-";
const found = sites.value.find((s: any) => s.id === id);
return found?.name || id;
};
const loadVisits = async () => {
if (!study.currentStudy || !route.params.subjectId) return;
try {
const { data } = await fetchVisits(study.currentStudy.id, route.params.subjectId as string);
visits.value = (data as any).items || (Array.isArray(data) ? data : []);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "访视加载失败");
}
};
const onAction = async (action: ActionConfig) => {
if (!study.currentStudy || !subject.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap[action.key],
stateMachine: subjectMachine,
currentState: subject.value.status,
actionKey: action.key,
target: { id: subject.value.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: subject.value.id,
action: action.key,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示").catch(() => null);
if (!ok) return;
}
const payload: Record<string, any> = { status: action.to };
if (action.to === "ENROLLED") payload.enrollment_date = new Date().toISOString().slice(0, 10);
if (action.to === "COMPLETED") payload.completion_date = new Date().toISOString().slice(0, 10);
if (action.to === "DROPPED") payload.drop_reason = "Dropped";
try {
await updateSubject(study.currentStudy.id, subject.value.id, payload);
ElMessage.success("状态已更新");
logAudit("SUBJECT_STATUS_CHANGED", {
targetId: subject.value.id,
targetName: subject.value.subject_no,
before: { status: subject.value.status },
after: { status: action.to },
severity: "normal",
});
await loadSubject();
await loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
logAudit("SUBJECT_STATUS_CHANGED", {
targetId: subject.value.id,
targetName: subject.value.subject_no,
before: { status: subject.value.status },
after: { status: action.to },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSites();
await loadSubject();
await loadVisits();
});
</script>
<style scoped>
.page-container {
display: flex;
flex-direction: column;
gap: var(--ctms-spacing-lg);
}
.subject-header-card {
background-color: #fff;
border-radius: var(--ctms-radius);
padding: 24px;
border: 1px solid var(--ctms-border-color);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header-main {
display: flex;
gap: 40px;
}
.subject-brand {
display: flex;
align-items: center;
gap: 16px;
}
.brand-avatar {
width: 48px;
height: 48px;
background-color: #f1f5f9;
color: var(--ctms-text-main);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
}
.subject-no {
font-size: 22px;
font-weight: 600;
color: var(--ctms-text-main);
margin: 0;
}
.site-info {
font-size: 13px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.meta-info {
font-size: 12px;
color: var(--ctms-text-disabled);
margin-top: 4px;
}
.subject-status {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-label {
font-size: 11px;
color: var(--ctms-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-tag {
font-weight: 600;
padding: 0 12px;
}
.header-actions {
display: flex;
gap: 12px;
}
.action-btn {
min-width: 88px;
}
.info-section {
background-color: #fff;
border-radius: var(--ctms-radius);
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.section-header {
padding: 16px 24px;
border-bottom: 1px solid var(--ctms-border-color);
background-color: #fafafa;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--ctms-text-main);
font-size: 15px;
}
.section-desc {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 6px;
}
.section-body {
padding: 24px;
}
.loading-state {
padding: 40px;
background-color: #fff;
border-radius: var(--ctms-radius);
border: 1px solid var(--ctms-border-color);
}
</style>
-295
View File
@@ -1,295 +0,0 @@
<template>
<div class="page-container">
<div class="page-header">
<div class="header-info">
<h1 class="page-title">受试者管理</h1>
<p class="page-subtitle">查看与维护当前项目的所有受试者信息</p>
</div>
<div class="header-actions">
<el-button type="primary" v-if="canEdit" @click="showForm = true">
<el-icon><Plus /></el-icon> 新增受试者
</el-button>
</div>
</div>
<el-card class="filter-card">
<div class="filter-layout">
<div class="filter-items">
<div class="filter-item">
<span class="filter-label">所属中心</span>
<el-select
v-model="filters.site_id"
placeholder="全部中心"
clearable
filterable
class="filter-select"
@change="loadSubjects"
>
<el-option v-for="s in sites" :key="s.id" :label="s.name || s.id" :value="s.id" />
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">当前状态</span>
<el-select
v-model="filters.status"
placeholder="全部状态"
clearable
class="filter-select"
@change="loadSubjects"
>
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
</div>
</div>
<div class="filter-reset">
<el-button link @click="resetFilters">重置筛选</el-button>
</div>
</div>
</el-card>
<el-card class="table-card" v-loading="loading">
<el-table :data="subjects" style="width: 100%" @row-click="goDetail" class="ctms-table">
<el-table-column prop="subject_no" label="受试者编号" min-width="120">
<template #default="scope">
<span class="subject-no">{{ scope.row.subject_no }}</span>
</template>
</el-table-column>
<el-table-column prop="site_id" label="中心名称" min-width="180">
<template #default="scope">
{{ siteName(scope.row.site_id) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)" size="small">
{{ statusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="screening_date" label="筛选日期" width="140" />
<el-table-column prop="enrollment_date" label="入组日期" width="140" />
<el-table-column label="操作" width="100" fixed="right" align="right">
<template #default>
<el-button link type="primary">详细资料</el-button>
</template>
</el-table-column>
<template #empty>
<StateEmpty description="暂无符合条件的受试者" />
</template>
</el-table>
<div class="pagination-container">
<el-pagination
background
layout="total, prev, pager, next, sizes"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</div>
</el-card>
<SubjectForm v-model="showForm" :sites="sites" @success="loadSubjects" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { fetchSubjects } from "../api/subjects";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import SubjectForm from "../components/SubjectForm.vue";
import StateEmpty from "../components/StateEmpty.vue";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const subjects = ref<any[]>([]);
const sites = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const siteMap = computed(() =>
sites.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.name || cur.id;
return acc;
}, {})
);
const statuses = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"];
const filters = ref({
site_id: "",
status: "",
});
const showForm = ref(false);
const canEdit = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "CRA";
});
const loadSubjects = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
};
if (filters.value.site_id) params.site_id = filters.value.site_id;
if (filters.value.status) params.status_filter = filters.value.status;
const { data } = await fetchSubjects(study.currentStudy.id, params);
if (Array.isArray(data)) {
subjects.value = data;
total.value = data.length;
} else {
subjects.value = data.items || [];
total.value = data.total || subjects.value.length;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "受试者加载失败");
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.value = { site_id: "", status: "" };
loadSubjects();
};
const onPageChange = (p: number) => {
page.value = p;
loadSubjects();
};
const goDetail = (row: any) => {
router.push(`/study/subjects/${row.id}`);
};
const loadSites = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSites(study.currentStudy.id);
sites.value = data.items || data;
} catch {
// ignore
}
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSites();
loadSubjects();
});
const statusLabel = (v: string) =>
({
SCREENING: "筛选中",
ENROLLED: "已入组",
COMPLETED: "已完成",
DROPPED: "已脱落",
}[v] || v);
const statusTagType = (v: string) => {
const map: Record<string, string> = {
SCREENING: "info",
ENROLLED: "warning",
COMPLETED: "success",
DROPPED: "danger",
};
return map[v] || "info";
};
const siteName = (id: string) => siteMap.value[id] || id || "-";
</script>
<style scoped>
.page-container {
display: flex;
flex-direction: column;
gap: var(--ctms-spacing-lg);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: var(--ctms-text-main);
margin: 0;
}
.page-subtitle {
font-size: 13px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.filter-card {
margin-bottom: 0;
}
.filter-layout {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
}
.filter-items {
display: flex;
gap: 20px;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: var(--ctms-text-regular);
}
.filter-select {
width: 200px;
}
.table-card {
flex: 1;
}
.subject-no {
font-weight: 600;
color: var(--ctms-text-main);
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
:deep(.el-table__row) {
cursor: pointer;
}
</style>
-237
View File
@@ -1,237 +0,0 @@
<template>
<div class="page">
<el-card>
<div class="table-toolbar">
<div class="filter-group">
<el-select v-model="filters.level" placeholder="核查层级" clearable @change="load" class="filter-item">
<el-option label="SDV 核查" value="SDV" />
<el-option label="SDR 核查" value="SDR" />
</el-select>
<SiteSelect
v-model="filters.site_id"
:study-id="study.currentStudy?.id || null"
placeholder="中心"
class="filter-item site-selector"
@change="load"
/>
</div>
<div class="action-group">
<el-button v-if="canEdit" type="primary" @click="openCreate">新增核查进度</el-button>
</div>
</div>
<VerificationTable
:verifications="verifications"
:can-edit="canEdit"
:loading="loading"
:subject-map="subjectMap"
:verifier-map="verifierMap"
@edit="openEdit"
/>
</el-card>
<el-dialog :title="editForm.id ? '更新核查进度' : '新增核查进度'" v-model="showEdit" width="520px">
<el-form :model="editForm" label-width="120px">
<el-form-item label="受试者">
<SubjectSelect
v-model="editForm.subject_id"
:study-id="study.currentStudy?.id || null"
:disabled="!!editForm.id"
/>
</el-form-item>
<el-form-item label="中心">
<SiteSelect v-model="editForm.site_id" :study-id="study.currentStudy?.id || null" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="editForm.level" placeholder="请选择">
<el-option label="SDV 核查" value="SDV" />
<el-option label="SDR 核查" value="SDR" />
</el-select>
</el-form-item>
<el-form-item label="完成度">
<el-input-number v-model="editForm.percent" :min="0" :max="100" />
</el-form-item>
<el-form-item label="核查日期">
<el-date-picker v-model="editForm.last_verified_at" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.notes" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitEdit">提交</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchVerifications, upsertVerification } from "../api/verifications";
import { fetchSubjects } from "../api/subjects";
import { listMembers } from "../api/members";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import VerificationTable from "../components/VerificationTable.vue";
import SiteSelect from "../components/selectors/SiteSelect.vue";
import SubjectSelect from "../components/selectors/SubjectSelect.vue";
import { displayDate, getMemberDisplayName } from "../utils/display";
const study = useStudyStore();
const auth = useAuthStore();
const verifications = ref<any[]>([]);
const subjects = ref<any[]>([]);
const members = ref<any[]>([]);
const loading = ref(false);
const submitting = ref(false);
const showEdit = ref(false);
const editForm = ref<any>({});
const filters = ref({
level: "",
site_id: "",
});
const canEdit = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "CRA";
});
const load = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = {};
if (filters.value.level) params.level = filters.value.level;
if (filters.value.site_id) params.site_id = filters.value.site_id;
const { data } = await fetchVerifications(study.currentStudy.id, params);
verifications.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "核查进度加载失败");
} finally {
loading.value = false;
}
};
const openEdit = (row: any) => {
if (!canEdit.value) return;
editForm.value = { ...row };
showEdit.value = true;
};
const openCreate = () => {
if (!canEdit.value) return;
editForm.value = { subject_id: "", site_id: "", level: filters.value.level || "SDV", percent: 0, notes: "" };
showEdit.value = true;
};
const submitEdit = async () => {
if (!study.currentStudy) return;
if (!editForm.value.subject_id) {
ElMessage.warning("请输入受试者ID");
return;
}
if (!editForm.value.level) {
ElMessage.warning("请选择类型");
return;
}
submitting.value = true;
try {
await upsertVerification(study.currentStudy.id, {
subject_id: editForm.value.subject_id,
site_id: editForm.value.site_id || null,
level: editForm.value.level,
percent: editForm.value.percent,
last_verified_at: editForm.value.last_verified_at,
verifier_id: editForm.value.verifier_id,
notes: editForm.value.notes,
});
ElMessage.success("更新成功");
showEdit.value = false;
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
} finally {
submitting.value = false;
}
};
const loadSubjects = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSubjects(study.currentStudy.id, { limit: 1000 });
subjects.value = data.items || data || [];
} catch {
subjects.value = [];
}
};
const loadMembers = async () => {
if (!study.currentStudy) return;
try {
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const subjectMap = computed(() =>
subjects.value.reduce<Record<string, string>>((acc, cur) => {
if (cur?.id) acc[cur.id] = cur.subject_no || cur.id;
return acc;
}, {})
);
const verifierMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
if (cur?.id && username) acc[cur.id] = username;
return acc;
}, {})
);
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSubjects(), loadMembers()]);
load();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.filter-item {
width: 160px;
}
.site-selector {
width: 220px;
}
.spacer {
flex: 1;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+1 -1
View File
@@ -81,7 +81,7 @@ const statusLabel = (status: string) =>
const enterProject = () => {
if (!project.value) return;
studyStore.setCurrentStudy({ ...project.value, role_in_study: project.value.role_in_study || "PM" } as Study);
router.push("/study/home");
router.push("/project/overview");
};
const goMembers = () => {
+1 -1
View File
@@ -92,7 +92,7 @@ const goSites = (row: Study) => {
const enterStudy = (row: Study) => {
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
router.push("/study/home");
router.push("/project/overview");
};
const goDetail = (row: Study) => {
+115
View File
@@ -0,0 +1,115 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">运输记录详情</h1>
<p class="page-subtitle">查看运输/流向信息与附件</p>
</div>
<div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button>
<el-button @click="goBack">返回</el-button>
</div>
</div>
<el-card v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="类型">{{ detail.direction || "—" }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detail.status || "—" }}</el-descriptions-item>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ displayDate(detail.ship_date) }}</el-descriptions-item>
<el-descriptions-item label="快递公司">{{ detail.carrier || "—" }}</el-descriptions-item>
<el-descriptions-item label="运单号">{{ detail.tracking_no || "—" }}</el-descriptions-item>
<el-descriptions-item label="发出方">{{ detail.from_party || "—" }}</el-descriptions-item>
<el-descriptions-item label="接收方">{{ detail.to_party || "—" }}</el-descriptions-item>
<el-descriptions-item label="物品描述" :span="2">{{ detail.drug_desc || "—" }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "—" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<AttachmentList
v-if="shipmentId"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getDrugShipment } from "../../api/drugShipments";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const shipmentId = route.params.shipmentId as string;
const studyId = study.currentStudy?.id || "";
const detail = reactive<any>({
direction: "",
status: "",
site_name: "",
ship_date: "",
carrier: "",
tracking_no: "",
from_party: "",
to_party: "",
drug_desc: "",
remark: "",
});
const load = async () => {
if (!studyId || !shipmentId) return;
loading.value = true;
try {
const { data } = await getDrugShipment(studyId, shipmentId);
Object.assign(detail, data);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const goEdit = () => router.push(`/drug/shipments/${shipmentId}/edit`);
const goBack = () => router.push("/drug/shipments");
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);
}
.actions {
display: flex;
gap: 8px;
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ isEdit ? "编辑运输记录" : "新增运输记录" }}</h1>
<p class="page-subtitle">记录寄送/回收流向</p>
</div>
<el-button @click="goBack">返回</el-button>
</div>
<el-card>
<el-form :model="form" label-width="120px">
<el-form-item label="类型" required>
<el-select v-model="form.direction" placeholder="选择类型">
<el-option label="寄送" value="寄送" />
<el-option label="回收" value="回收" />
</el-select>
</el-form-item>
<el-form-item label="分中心" required>
<el-input v-model="form.site_name" placeholder="输入分中心/机构名称" />
</el-form-item>
<el-form-item label="物品描述" required>
<el-input v-model="form.drug_desc" type="textarea" :rows="3" placeholder="药品名称/规格/数量等" />
</el-form-item>
<el-form-item label="日期">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="快递公司">
<el-input v-model="form.carrier" placeholder="输入快递公司" />
</el-form-item>
<el-form-item label="运单号">
<el-input v-model="form.tracking_no" placeholder="输入运单号" />
</el-form-item>
<el-form-item label="发出方">
<el-input v-model="form.from_party" placeholder="输入发出方" />
</el-form-item>
<el-form-item label="接收方">
<el-input v-model="form.to_party" placeholder="输入接收方" />
</el-form-item>
<el-form-item label="状态" required>
<el-select v-model="form.status" placeholder="选择状态">
<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 label="备注">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="补充说明" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
<AttachmentList
v-if="isEdit && shipmentId"
:study-id="studyId"
entity-type="drug_shipment"
:entity-id="shipmentId"
/>
<el-card v-else class="hint-card">
<StateEmpty description="保存后可上传交接材料" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createDrugShipment, getDrugShipment, updateDrugShipment } from "../../api/drugShipments";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const isEdit = computed(() => !!shipmentId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({
direction: "寄送",
site_name: "",
drug_desc: "",
ship_date: "",
carrier: "",
tracking_no: "",
from_party: "",
to_party: "",
status: "待寄出",
remark: "",
});
const load = async () => {
if (!isEdit.value || !studyId.value || !shipmentId.value) return;
try {
const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(form, {
direction: data.direction || "寄送",
site_name: data.site_name || "",
drug_desc: data.drug_desc || "",
ship_date: data.ship_date || "",
carrier: data.carrier || "",
tracking_no: data.tracking_no || "",
from_party: data.from_party || "",
to_party: data.to_party || "",
status: data.status || "待寄出",
remark: data.remark || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
}
};
const submit = async () => {
if (!studyId.value) return;
saving.value = true;
try {
const payload = {
direction: form.direction,
site_name: form.site_name,
drug_desc: form.drug_desc,
ship_date: form.ship_date || null,
carrier: form.carrier || null,
tracking_no: form.tracking_no || null,
from_party: form.from_party || null,
to_party: form.to_party || null,
status: form.status,
remark: form.remark || null,
};
if (isEdit.value && shipmentId.value) {
await updateDrugShipment(studyId.value, shipmentId.value, payload);
ElMessage.success("已保存");
router.push(`/drug/shipments/${shipmentId.value}`);
} else {
const { data } = await createDrugShipment(studyId.value, payload);
ElMessage.success("已创建");
router.push(`/drug/shipments/${data.id}`);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/drug/shipments");
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);
}
.hint-card {
padding: 12px;
}
</style>
@@ -0,0 +1,108 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">合同费用详情</h1>
<p class="page-subtitle">查看合同费用与附件</p>
</div>
<div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button>
<el-button @click="goBack">返回</el-button>
</div>
</div>
<el-card v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item>
<el-descriptions-item label="合同编号">{{ detail.contract_no || "—" }}</el-descriptions-item>
<el-descriptions-item label="签署日期">{{ displayDate(detail.signed_date) }}</el-descriptions-item>
<el-descriptions-item label="合同金额">
{{ detail.amount }} {{ detail.currency || "CNY" }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "—" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<AttachmentList
v-if="contractId"
:study-id="studyId"
entity-type="finance_contract"
:entity-id="contractId"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getFinanceContract } from "../../api/financeContracts";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const contractId = route.params.contractId as string;
const studyId = study.currentStudy?.id || "";
const detail = reactive<any>({
site_name: "",
contract_no: "",
signed_date: "",
amount: "",
currency: "",
remark: "",
});
const load = async () => {
if (!studyId || !contractId) return;
loading.value = true;
try {
const { data } = await getFinanceContract(studyId, contractId);
Object.assign(detail, data);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const goEdit = () => router.push(`/finance/contracts/${contractId}/edit`);
const goBack = () => router.push("/finance/contracts");
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);
}
.actions {
display: flex;
gap: 8px;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ isEdit ? "编辑合同费用" : "新增合同费用" }}</h1>
<p class="page-subtitle">填写分中心合同信息并保存</p>
</div>
<el-button @click="goBack">返回</el-button>
</div>
<el-card>
<el-form ref="formRef" :model="form" label-width="110px">
<el-form-item label="分中心" prop="site_name" required>
<el-input v-model="form.site_name" placeholder="输入分中心/机构名称" />
</el-form-item>
<el-form-item label="合同编号" prop="contract_no" required>
<el-input v-model="form.contract_no" placeholder="输入合同编号" />
</el-form-item>
<el-form-item label="签署日期">
<el-date-picker v-model="form.signed_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="合同金额" prop="amount" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="1000" />
</el-form-item>
<el-form-item label="币种">
<el-select v-model="form.currency" placeholder="选择币种">
<el-option label="CNY" value="CNY" />
<el-option label="USD" value="USD" />
<el-option label="EUR" value="EUR" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="补充说明" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
<AttachmentList
v-if="isEdit && contractId"
:study-id="studyId"
entity-type="finance_contract"
:entity-id="contractId"
/>
<el-card v-else class="hint-card">
<StateEmpty description="保存后可上传合同附件" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createFinanceContract, getFinanceContract, updateFinanceContract } from "../../api/financeContracts";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const contractId = computed(() => route.params.contractId as string | undefined);
const isEdit = computed(() => !!contractId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({
site_name: "",
contract_no: "",
signed_date: "",
amount: 0,
currency: "CNY",
remark: "",
});
const load = async () => {
if (!isEdit.value || !studyId.value || !contractId.value) return;
try {
const { data } = await getFinanceContract(studyId.value, contractId.value);
Object.assign(form, {
site_name: data.site_name || "",
contract_no: data.contract_no || "",
signed_date: data.signed_date || "",
amount: Number(data.amount || 0),
currency: data.currency || "CNY",
remark: data.remark || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
}
};
const submit = async () => {
if (!studyId.value) return;
saving.value = true;
try {
const payload = {
site_name: form.site_name,
contract_no: form.contract_no,
signed_date: form.signed_date || null,
amount: form.amount,
currency: form.currency,
remark: form.remark || null,
};
if (isEdit.value && contractId.value) {
await updateFinanceContract(studyId.value, contractId.value, payload);
ElMessage.success("已保存");
router.push(`/finance/contracts/${contractId.value}`);
} else {
const { data } = await createFinanceContract(studyId.value, payload);
ElMessage.success("已创建");
router.push(`/finance/contracts/${data.id}`);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/finance/contracts");
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);
}
.hint-card {
padding: 12px;
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">特殊费用详情</h1>
<p class="page-subtitle">查看费用记录与附件</p>
</div>
<div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button>
<el-button @click="goBack">返回</el-button>
</div>
</div>
<el-card v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item>
<el-descriptions-item label="费用类型">{{ detail.fee_type || "—" }}</el-descriptions-item>
<el-descriptions-item label="发生日期">{{ displayDate(detail.occur_date) }}</el-descriptions-item>
<el-descriptions-item label="人员">{{ detail.staff_name || "—" }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ detail.amount || "—" }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "—" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<AttachmentList
v-if="specialId"
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getFinanceSpecial } from "../../api/financeSpecials";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const specialId = route.params.specialId as string;
const studyId = study.currentStudy?.id || "";
const detail = reactive<any>({
site_name: "",
fee_type: "",
occur_date: "",
staff_name: "",
amount: "",
remark: "",
});
const load = async () => {
if (!studyId || !specialId) return;
loading.value = true;
try {
const { data } = await getFinanceSpecial(studyId, specialId);
Object.assign(detail, data);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const goEdit = () => router.push(`/finance/special/${specialId}/edit`);
const goBack = () => router.push("/finance/special");
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);
}
.actions {
display: flex;
gap: 8px;
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ isEdit ? "编辑特殊费用" : "新增特殊费用" }}</h1>
<p class="page-subtitle">记录差旅住宿等特殊费用</p>
</div>
<el-button @click="goBack">返回</el-button>
</div>
<el-card>
<el-form :model="form" label-width="110px">
<el-form-item label="分中心" required>
<el-input v-model="form.site_name" placeholder="输入分中心/机构名称" />
</el-form-item>
<el-form-item label="费用类型" required>
<el-select v-model="form.fee_type" placeholder="选择类型">
<el-option label="差旅" value="差旅" />
<el-option label="住宿" value="住宿" />
<el-option label="交通" value="交通" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="金额" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" :step="100" />
</el-form-item>
<el-form-item label="发生日期">
<el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="人员">
<el-input v-model="form.staff_name" placeholder="输入人员姓名" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="补充说明" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
<AttachmentList
v-if="isEdit && specialId"
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
/>
<el-card v-else class="hint-card">
<StateEmpty description="保存后可上传凭证附件" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const specialId = computed(() => route.params.specialId as string | undefined);
const isEdit = computed(() => !!specialId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({
site_name: "",
fee_type: "差旅",
amount: 0,
occur_date: "",
staff_name: "",
remark: "",
});
const load = async () => {
if (!isEdit.value || !studyId.value || !specialId.value) return;
try {
const { data } = await getFinanceSpecial(studyId.value, specialId.value);
Object.assign(form, {
site_name: data.site_name || "",
fee_type: data.fee_type || "差旅",
amount: Number(data.amount || 0),
occur_date: data.occur_date || "",
staff_name: data.staff_name || "",
remark: data.remark || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
}
};
const submit = async () => {
if (!studyId.value) return;
saving.value = true;
try {
const payload = {
site_name: form.site_name,
fee_type: form.fee_type,
amount: form.amount,
occur_date: form.occur_date || null,
staff_name: form.staff_name || null,
remark: form.remark || null,
};
if (isEdit.value && specialId.value) {
await updateFinanceSpecial(studyId.value, specialId.value, payload);
ElMessage.success("已保存");
router.push(`/finance/special/${specialId.value}`);
} else {
const { data } = await createFinanceSpecial(studyId.value, payload);
ElMessage.success("已创建");
router.push(`/finance/special/${data.id}`);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/finance/special");
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);
}
.hint-card {
padding: 12px;
}
</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>
+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>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">注意事项详情</h1>
<p class="page-subtitle">查看注意事项与附件</p>
</div>
<div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button>
<el-button @click="goBack">返回</el-button>
</div>
</div>
<el-card v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item>
<el-descriptions-item label="重要等级">{{ detail.level || "—" }}</el-descriptions-item>
<el-descriptions-item label="标题" :span="2">{{ detail.title || "—" }}</el-descriptions-item>
<el-descriptions-item label="内容" :span="2">{{ detail.content || "—" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<AttachmentList
v-if="noteId"
:study-id="studyId"
entity-type="knowledge_note"
:entity-id="noteId"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getKnowledgeNote } from "../../api/knowledgeNotes";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const noteId = route.params.noteId as string;
const studyId = study.currentStudy?.id || "";
const detail = reactive<any>({
site_name: "",
title: "",
level: "",
content: "",
});
const load = async () => {
if (!studyId || !noteId) return;
loading.value = true;
try {
const { data } = await getKnowledgeNote(studyId, noteId);
Object.assign(detail, data);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const goEdit = () => router.push(`/knowledge/notes/${noteId}/edit`);
const goBack = () => router.push("/knowledge/notes");
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);
}
.actions {
display: flex;
gap: 8px;
}
</style>
+143
View File
@@ -0,0 +1,143 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ isEdit ? "编辑注意事项" : "新增注意事项" }}</h1>
<p class="page-subtitle">记录分中心特殊问题</p>
</div>
<el-button @click="goBack">返回</el-button>
</div>
<el-card>
<el-form :model="form" label-width="120px">
<el-form-item label="分中心" required>
<el-input v-model="form.site_name" placeholder="输入分中心/机构名称" />
</el-form-item>
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="输入标题" />
</el-form-item>
<el-form-item label="重要等级">
<el-input v-model="form.level" placeholder="例如 高/中/低" />
</el-form-item>
<el-form-item label="内容" required>
<el-input v-model="form.content" type="textarea" :rows="4" placeholder="输入内容" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
<AttachmentList
v-if="isEdit && noteId"
:study-id="studyId"
entity-type="knowledge_note"
:entity-id="noteId"
/>
<el-card v-else class="hint-card">
<StateEmpty description="保存后可上传附件" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createKnowledgeNote, getKnowledgeNote, updateKnowledgeNote } from "../../api/knowledgeNotes";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const noteId = computed(() => route.params.noteId as string | undefined);
const isEdit = computed(() => !!noteId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({
site_name: "",
title: "",
level: "",
content: "",
});
const load = async () => {
if (!isEdit.value || !studyId.value || !noteId.value) return;
try {
const { data } = await getKnowledgeNote(studyId.value, noteId.value);
Object.assign(form, {
site_name: data.site_name || "",
title: data.title || "",
level: data.level || "",
content: data.content || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
}
};
const submit = async () => {
if (!studyId.value) return;
saving.value = true;
try {
const payload = {
site_name: form.site_name,
title: form.title,
level: form.level || null,
content: form.content,
};
if (isEdit.value && noteId.value) {
await updateKnowledgeNote(studyId.value, noteId.value, payload);
ElMessage.success("已保存");
router.push(`/knowledge/notes/${noteId.value}`);
} else {
const { data } = await createKnowledgeNote(studyId.value, payload);
ElMessage.success("已创建");
router.push(`/knowledge/notes/${data.id}`);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/knowledge/notes");
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);
}
.hint-card {
padding: 12px;
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">伦理记录详情</h1>
<p class="page-subtitle">查看伦理记录与附件</p>
</div>
<div class="actions">
<el-button type="primary" @click="goEdit">编辑</el-button>
<el-button @click="goBack">返回</el-button>
</div>
</div>
<el-card v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions-item label="递交日期">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
<el-descriptions-item label="受理日期">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
<el-descriptions-item label="会议日期">{{ displayDate(detail.meeting_date) }}</el-descriptions-item>
<el-descriptions-item label="批准日期">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
<el-descriptions-item label="批件号">{{ detail.approval_no || "—" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { getEthics } from "../../api/startup";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || "";
const detail = reactive<any>({
submit_date: "",
accept_date: "",
meeting_date: "",
approved_date: "",
approval_no: "",
});
const load = async () => {
if (!studyId || !recordId) return;
loading.value = true;
try {
const { data } = await getEthics(studyId, recordId);
Object.assign(detail, data);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
const goEdit = () => router.push(`/startup/ethics/${recordId}/edit`);
const goBack = () => router.push("/startup/feasibility-ethics");
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);
}
.actions {
display: flex;
gap: 8px;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ isEdit ? "编辑伦理记录" : "新增伦理记录" }}</h1>
<p class="page-subtitle">维护伦理递交与审批信息</p>
</div>
<el-button @click="goBack">返回</el-button>
</div>
<el-card>
<el-form :model="form" label-width="120px">
<el-form-item label="递交日期">
<el-date-picker v-model="form.submit_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="受理日期">
<el-date-picker v-model="form.accept_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="会议日期">
<el-date-picker v-model="form.meeting_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="批准日期">
<el-date-picker v-model="form.approved_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="批件号">
<el-input v-model="form.approval_no" placeholder="输入批件号" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
<AttachmentList
v-if="isEdit && recordId"
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
/>
<el-card v-else class="hint-card">
<StateEmpty description="保存后可上传伦理附件" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createEthics, getEthics, updateEthics } from "../../api/startup";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const recordId = computed(() => route.params.recordId as string | undefined);
const isEdit = computed(() => !!recordId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({
submit_date: "",
accept_date: "",
meeting_date: "",
approved_date: "",
approval_no: "",
});
const load = async () => {
if (!isEdit.value || !studyId.value || !recordId.value) return;
try {
const { data } = await getEthics(studyId.value, recordId.value);
Object.assign(form, {
submit_date: data.submit_date || "",
accept_date: data.accept_date || "",
meeting_date: data.meeting_date || "",
approved_date: data.approved_date || "",
approval_no: data.approval_no || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
}
};
const submit = async () => {
if (!studyId.value) return;
saving.value = true;
try {
const payload = {
submit_date: form.submit_date || null,
accept_date: form.accept_date || null,
meeting_date: form.meeting_date || null,
approved_date: form.approved_date || null,
approval_no: form.approval_no || null,
};
if (isEdit.value && recordId.value) {
await updateEthics(studyId.value, recordId.value, payload);
ElMessage.success("已保存");
router.push(`/startup/ethics/${recordId.value}`);
} else {
const { data } = await createEthics(studyId.value, payload);
ElMessage.success("已创建");
router.push(`/startup/ethics/${data.id}`);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/startup/feasibility-ethics");
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);
}
.hint-card {
padding: 12px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More