信息架构/菜单框架大重构-20260109
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
@@ -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 }
|
||||
);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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="请选择分类">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -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: "确定将该受试者标记为脱落?",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user