信息架构/菜单框架大重构-20260109
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,103 @@
|
||||
<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.approved_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目编号">{{ detail.project_no || "—" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_feasibility"
|
||||
: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 { getFeasibility } 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: "",
|
||||
approved_date: "",
|
||||
project_no: "",
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !recordId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await getFeasibility(studyId, recordId);
|
||||
Object.assign(detail, data);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/startup/feasibility/${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,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="递交日期">
|
||||
<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.approved_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目编号">
|
||||
<el-input v-model="form.project_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_feasibility"
|
||||
: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 { createFeasibility, getFeasibility, updateFeasibility } 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: "",
|
||||
approved_date: "",
|
||||
project_no: "",
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (!isEdit.value || !studyId.value || !recordId.value) return;
|
||||
try {
|
||||
const { data } = await getFeasibility(studyId.value, recordId.value);
|
||||
Object.assign(form, {
|
||||
submit_date: data.submit_date || "",
|
||||
accept_date: data.accept_date || "",
|
||||
approved_date: data.approved_date || "",
|
||||
project_no: data.project_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,
|
||||
approved_date: form.approved_date || null,
|
||||
project_no: form.project_no || null,
|
||||
};
|
||||
if (isEdit.value && recordId.value) {
|
||||
await updateFeasibility(studyId.value, recordId.value, payload);
|
||||
ElMessage.success("已保存");
|
||||
router.push(`/startup/feasibility/${recordId.value}`);
|
||||
} else {
|
||||
const { data } = await createFeasibility(studyId.value, payload);
|
||||
ElMessage.success("已创建");
|
||||
router.push(`/startup/feasibility/${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>
|
||||
@@ -0,0 +1,101 @@
|
||||
<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.kickoff_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="参训人员">
|
||||
{{ (detail.attendees || []).join("、") || "—" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="meetingId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_kickoff"
|
||||
:entity-id="meetingId"
|
||||
/>
|
||||
</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 { getKickoff } 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 meetingId = route.params.meetingId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
|
||||
const detail = reactive<any>({
|
||||
kickoff_date: "",
|
||||
attendees: [],
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !meetingId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await getKickoff(studyId, meetingId);
|
||||
Object.assign(detail, data);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/startup/kickoff/${meetingId}/edit`);
|
||||
const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
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="启动会日期">
|
||||
<el-date-picker v-model="form.kickoff_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参训人员">
|
||||
<el-input
|
||||
v-model="form.attendees"
|
||||
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 && meetingId"
|
||||
:study-id="studyId"
|
||||
entity-type="startup_kickoff"
|
||||
:entity-id="meetingId"
|
||||
/>
|
||||
<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 { createKickoff, getKickoff, updateKickoff } 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 meetingId = computed(() => route.params.meetingId as string | undefined);
|
||||
const isEdit = computed(() => !!meetingId.value);
|
||||
const studyId = computed(() => study.currentStudy?.id || "");
|
||||
|
||||
const form = reactive({
|
||||
kickoff_date: "",
|
||||
attendees: "",
|
||||
});
|
||||
|
||||
const parseAttendees = (value: string) => {
|
||||
return value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!isEdit.value || !studyId.value || !meetingId.value) return;
|
||||
try {
|
||||
const { data } = await getKickoff(studyId.value, meetingId.value);
|
||||
Object.assign(form, {
|
||||
kickoff_date: data.kickoff_date || "",
|
||||
attendees: Array.isArray(data.attendees) ? data.attendees.join("\n") : "",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
kickoff_date: form.kickoff_date || null,
|
||||
attendees: parseAttendees(form.attendees),
|
||||
};
|
||||
if (isEdit.value && meetingId.value) {
|
||||
await updateKickoff(studyId.value, meetingId.value, payload);
|
||||
ElMessage.success("已保存");
|
||||
router.push(`/startup/kickoff/${meetingId.value}`);
|
||||
} else {
|
||||
const { data } = await createKickoff(studyId.value, payload);
|
||||
ElMessage.success("已创建");
|
||||
router.push(`/startup/kickoff/${data.id}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
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,111 @@
|
||||
<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.name || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">{{ detail.role || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分中心">{{ detail.site_name || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已培训">{{ detail.trained ? "是" : "否" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="培训日期">{{ displayDate(detail.trained_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已授权">{{ detail.authorized ? "是" : "否" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="授权日期">{{ displayDate(detail.authorized_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "—" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<AttachmentList
|
||||
v-if="recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
: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 { getTrainingAuthorization } 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>({
|
||||
name: "",
|
||||
role: "",
|
||||
site_name: "",
|
||||
trained: false,
|
||||
authorized: false,
|
||||
trained_date: "",
|
||||
authorized_date: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (!studyId || !recordId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await getTrainingAuthorization(studyId, recordId);
|
||||
Object.assign(detail, data);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/startup/training/${recordId}/edit`);
|
||||
const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
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,167 @@
|
||||
<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.name" placeholder="输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-input v-model="form.role" placeholder="输入角色" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分中心">
|
||||
<el-input v-model="form.site_name" placeholder="输入分中心" />
|
||||
</el-form-item>
|
||||
<el-form-item label="已培训">
|
||||
<el-switch v-model="form.trained" />
|
||||
</el-form-item>
|
||||
<el-form-item label="培训日期">
|
||||
<el-date-picker v-model="form.trained_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
<el-form-item label="已授权">
|
||||
<el-switch v-model="form.authorized" />
|
||||
</el-form-item>
|
||||
<el-form-item label="授权日期">
|
||||
<el-date-picker v-model="form.authorized_date" type="date" value-format="YYYY-MM-DD" 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 && recordId"
|
||||
:study-id="studyId"
|
||||
entity-type="training_authorization"
|
||||
: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 { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } 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({
|
||||
name: "",
|
||||
role: "",
|
||||
site_name: "",
|
||||
trained: false,
|
||||
authorized: false,
|
||||
trained_date: "",
|
||||
authorized_date: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (!isEdit.value || !studyId.value || !recordId.value) return;
|
||||
try {
|
||||
const { data } = await getTrainingAuthorization(studyId.value, recordId.value);
|
||||
Object.assign(form, {
|
||||
name: data.name || "",
|
||||
role: data.role || "",
|
||||
site_name: data.site_name || "",
|
||||
trained: !!data.trained,
|
||||
authorized: !!data.authorized,
|
||||
trained_date: data.trained_date || "",
|
||||
authorized_date: data.authorized_date || "",
|
||||
remark: data.remark || "",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
role: form.role || null,
|
||||
site_name: form.site_name || null,
|
||||
trained: form.trained,
|
||||
authorized: form.authorized,
|
||||
trained_date: form.trained_date || null,
|
||||
authorized_date: form.authorized_date || null,
|
||||
remark: form.remark || null,
|
||||
};
|
||||
if (isEdit.value && recordId.value) {
|
||||
await updateTrainingAuthorization(studyId.value, recordId.value, payload);
|
||||
ElMessage.success("已保存");
|
||||
router.push(`/startup/training/${recordId.value}`);
|
||||
} else {
|
||||
const { data } = await createTrainingAuthorization(studyId.value, payload);
|
||||
ElMessage.success("已创建");
|
||||
router.push(`/startup/training/${data.id}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => router.push("/startup/meeting-auth");
|
||||
|
||||
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,525 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">受试者详情</h1>
|
||||
<p class="page-subtitle">查看病史、访视与 AE 信息</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.subject_no || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分中心">{{ siteMap[detail.site_id] || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ detail.status || "—" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="筛选日期">{{ displayDate(detail.screening_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="入组日期">{{ displayDate(detail.enrollment_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成日期">{{ displayDate(detail.completion_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="退出原因" :span="2">{{ detail.drop_reason || "—" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="病史" name="history">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openHistoryDialog()">新增病史</el-button>
|
||||
</div>
|
||||
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%">
|
||||
<el-table-column prop="record_date" label="日期" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="内容" min-width="240" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openHistoryDialog(scope.row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeHistory(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingHistory && historyItems.length === 0" description="暂无病史记录" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="访视" name="visits">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openVisitDialog()">新增访视</el-button>
|
||||
</div>
|
||||
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%">
|
||||
<el-table-column prop="visit_code" label="访视编号" width="120" />
|
||||
<el-table-column prop="visit_name" label="访视名称" min-width="140" />
|
||||
<el-table-column prop="planned_date" label="计划日期" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.planned_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_date" label="实际日期" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.actual_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="openVisitDialog(scope.row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeVisit(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingVisits && visitItems.length === 0" description="暂无访视记录" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="AE 信息" name="ae">
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="openAeDialog()">新增 AE</el-button>
|
||||
</div>
|
||||
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%">
|
||||
<el-table-column prop="onset_date" label="发生日期" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="term" label="事件" min-width="140" />
|
||||
<el-table-column prop="seriousness" label="严重程度" width="120" />
|
||||
<el-table-column prop="causality" label="关联性" width="120" />
|
||||
<el-table-column prop="outcome" label="结局" width="120" />
|
||||
<el-table-column prop="description" label="备注" min-width="200" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openAeDialog(scope.row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeAe(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<StateEmpty v-if="!loadingAes && aeItems.length === 0" description="暂无 AE 记录" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="historyDialogVisible" title="病史记录" width="520px">
|
||||
<el-form :model="historyForm" label-width="90px">
|
||||
<el-form-item label="日期">
|
||||
<el-date-picker v-model="historyForm.record_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" required>
|
||||
<el-input v-model="historyForm.content" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="historyDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveHistory">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="visitDialogVisible" title="访视记录" width="560px">
|
||||
<el-form :model="visitForm" label-width="100px">
|
||||
<template v-if="!visitEditingId">
|
||||
<el-form-item label="访视编号" required>
|
||||
<el-input v-model="visitForm.visit_code" />
|
||||
</el-form-item>
|
||||
<el-form-item label="访视名称" required>
|
||||
<el-input v-model="visitForm.visit_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="计划日期">
|
||||
<el-date-picker v-model="visitForm.planned_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="窗口开始">
|
||||
<el-date-picker v-model="visitForm.window_start" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="窗口结束">
|
||||
<el-date-picker v-model="visitForm.window_end" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="实际日期">
|
||||
<el-date-picker v-model="visitForm.actual_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="visitForm.status">
|
||||
<el-option label="计划中" value="PLANNED" />
|
||||
<el-option label="已完成" value="DONE" />
|
||||
<el-option label="未完成" value="MISSED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="visitForm.notes" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visitDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveVisit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="aeDialogVisible" title="AE 记录" width="560px">
|
||||
<el-form :model="aeForm" label-width="100px">
|
||||
<el-form-item label="事件" required>
|
||||
<el-input v-model="aeForm.term" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发生日期">
|
||||
<el-date-picker v-model="aeForm.onset_date" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="严重程度" required>
|
||||
<el-select v-model="aeForm.seriousness">
|
||||
<el-option label="严重" value="SERIOUS" />
|
||||
<el-option label="一般" value="NON_SERIOUS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="严重等级" required>
|
||||
<el-select v-model="aeForm.severity">
|
||||
<el-option label="轻度" value="MILD" />
|
||||
<el-option label="中度" value="MODERATE" />
|
||||
<el-option label="重度" value="SEVERE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联性">
|
||||
<el-input v-model="aeForm.causality" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结局">
|
||||
<el-input v-model="aeForm.outcome" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="aeForm.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="aeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAe">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { getSubject } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { listSubjectHistories, createSubjectHistory, updateSubjectHistory, deleteSubjectHistory } from "../../api/subjectHistories";
|
||||
import { fetchVisits, createVisit, updateVisit, deleteVisit } from "../../api/visits";
|
||||
import { fetchAes, createAe, updateAe, deleteAe } from "../../api/aes";
|
||||
import { displayDate } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const subjectId = route.params.subjectId as string;
|
||||
const studyId = study.currentStudy?.id || "";
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = reactive<any>({
|
||||
subject_no: "",
|
||||
site_id: "",
|
||||
status: "",
|
||||
screening_date: "",
|
||||
enrollment_date: "",
|
||||
completion_date: "",
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const activeTab = ref("history");
|
||||
|
||||
const historyItems = ref<any[]>([]);
|
||||
const loadingHistory = ref(false);
|
||||
const historyDialogVisible = ref(false);
|
||||
const historyEditingId = ref<string | null>(null);
|
||||
const historyForm = reactive({
|
||||
record_date: "",
|
||||
content: "",
|
||||
});
|
||||
|
||||
const visitItems = ref<any[]>([]);
|
||||
const loadingVisits = ref(false);
|
||||
const visitDialogVisible = ref(false);
|
||||
const visitEditingId = ref<string | null>(null);
|
||||
const visitForm = reactive<any>({
|
||||
visit_code: "",
|
||||
visit_name: "",
|
||||
planned_date: "",
|
||||
window_start: "",
|
||||
window_end: "",
|
||||
actual_date: "",
|
||||
status: "PLANNED",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const aeItems = ref<any[]>([]);
|
||||
const loadingAes = ref(false);
|
||||
const aeDialogVisible = ref(false);
|
||||
const aeEditingId = ref<string | null>(null);
|
||||
const aeForm = reactive({
|
||||
term: "",
|
||||
onset_date: "",
|
||||
seriousness: "NON_SERIOUS",
|
||||
severity: "MILD",
|
||||
causality: "",
|
||||
outcome: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const loadSites = async () => {
|
||||
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 loadSubject = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await getSubject(studyId, subjectId);
|
||||
Object.assign(detail, data);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistories = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingHistory.value = true;
|
||||
try {
|
||||
const { data } = await listSubjectHistories(studyId, subjectId);
|
||||
historyItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "病史加载失败");
|
||||
} finally {
|
||||
loadingHistory.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openHistoryDialog = (row?: any) => {
|
||||
historyEditingId.value = row?.id || null;
|
||||
historyForm.record_date = row?.record_date || "";
|
||||
historyForm.content = row?.content || "";
|
||||
historyDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveHistory = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
record_date: historyForm.record_date || null,
|
||||
content: historyForm.content,
|
||||
};
|
||||
if (historyEditingId.value) {
|
||||
await updateSubjectHistory(studyId, subjectId, historyEditingId.value, payload);
|
||||
} else {
|
||||
await createSubjectHistory(studyId, subjectId, payload);
|
||||
}
|
||||
historyDialogVisible.value = false;
|
||||
loadHistories();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const removeHistory = async (row: any) => {
|
||||
if (!studyId || !subjectId) return;
|
||||
const ok = await ElMessageBox.confirm("确认删除该病史记录?", "提示").catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteSubjectHistory(studyId, subjectId, row.id);
|
||||
loadHistories();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadVisits = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingVisits.value = true;
|
||||
try {
|
||||
const { data } = await fetchVisits(studyId, subjectId);
|
||||
visitItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "访视加载失败");
|
||||
} finally {
|
||||
loadingVisits.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openVisitDialog = (row?: any) => {
|
||||
visitEditingId.value = row?.id || null;
|
||||
if (row) {
|
||||
visitForm.actual_date = row.actual_date || "";
|
||||
visitForm.status = row.status || "PLANNED";
|
||||
visitForm.notes = row.notes || "";
|
||||
} else {
|
||||
visitForm.visit_code = "";
|
||||
visitForm.visit_name = "";
|
||||
visitForm.planned_date = "";
|
||||
visitForm.window_start = "";
|
||||
visitForm.window_end = "";
|
||||
visitForm.actual_date = "";
|
||||
visitForm.status = "PLANNED";
|
||||
visitForm.notes = "";
|
||||
}
|
||||
visitDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveVisit = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
if (visitEditingId.value) {
|
||||
const payload = {
|
||||
actual_date: visitForm.actual_date || null,
|
||||
status: visitForm.status || null,
|
||||
notes: visitForm.notes || null,
|
||||
};
|
||||
await updateVisit(studyId, subjectId, visitEditingId.value, payload);
|
||||
} else {
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
visit_code: visitForm.visit_code,
|
||||
visit_name: visitForm.visit_name,
|
||||
planned_date: visitForm.planned_date || null,
|
||||
window_start: visitForm.window_start || null,
|
||||
window_end: visitForm.window_end || null,
|
||||
};
|
||||
await createVisit(studyId, subjectId, payload);
|
||||
}
|
||||
visitDialogVisible.value = false;
|
||||
loadVisits();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const removeVisit = async (row: any) => {
|
||||
if (!studyId || !subjectId) return;
|
||||
const ok = await ElMessageBox.confirm("确认删除该访视记录?", "提示").catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteVisit(studyId, subjectId, row.id);
|
||||
loadVisits();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadAes = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
loadingAes.value = true;
|
||||
try {
|
||||
const { data } = await fetchAes(studyId, { subject_id: subjectId });
|
||||
aeItems.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "AE 加载失败");
|
||||
} finally {
|
||||
loadingAes.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openAeDialog = (row?: any) => {
|
||||
aeEditingId.value = row?.id || null;
|
||||
Object.assign(aeForm, {
|
||||
term: row?.term || "",
|
||||
onset_date: row?.onset_date || "",
|
||||
seriousness: row?.seriousness || "NON_SERIOUS",
|
||||
severity: row?.severity || "MILD",
|
||||
causality: row?.causality || "",
|
||||
outcome: row?.outcome || "",
|
||||
description: row?.description || "",
|
||||
});
|
||||
aeDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveAe = async () => {
|
||||
if (!studyId || !subjectId) return;
|
||||
try {
|
||||
const payload = {
|
||||
subject_id: subjectId,
|
||||
site_id: detail.site_id || null,
|
||||
term: aeForm.term,
|
||||
onset_date: aeForm.onset_date || null,
|
||||
seriousness: aeForm.seriousness,
|
||||
severity: aeForm.severity,
|
||||
causality: aeForm.causality || null,
|
||||
outcome: aeForm.outcome || null,
|
||||
description: aeForm.description || null,
|
||||
};
|
||||
if (aeEditingId.value) {
|
||||
await updateAe(studyId, aeEditingId.value, payload);
|
||||
} else {
|
||||
await createAe(studyId, payload);
|
||||
}
|
||||
aeDialogVisible.value = false;
|
||||
loadAes();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const removeAe = async (row: any) => {
|
||||
if (!studyId) return;
|
||||
const ok = await ElMessageBox.confirm("确认删除该 AE 记录?", "提示").catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteAe(studyId, row.id);
|
||||
loadAes();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const goEdit = () => router.push(`/subjects/${subjectId}/edit`);
|
||||
const goBack = () => router.push("/subjects");
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
await loadSubject();
|
||||
loadHistories();
|
||||
loadVisits();
|
||||
loadAes();
|
||||
});
|
||||
</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;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<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.subject_no" :disabled="isEdit" placeholder="输入受试者编号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分中心" required>
|
||||
<el-select v-model="form.site_id" :disabled="isEdit" placeholder="选择分中心">
|
||||
<el-option v-for="site in sites" :key="site.id" :label="site.name" :value="site.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="筛选日期">
|
||||
<el-date-picker
|
||||
v-model="form.screening_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="状态">
|
||||
<el-select v-model="form.status" placeholder="选择状态">
|
||||
<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 v-if="isEdit" label="入组日期">
|
||||
<el-date-picker v-model="form.enrollment_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="完成日期">
|
||||
<el-date-picker v-model="form.completion_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="退出原因">
|
||||
<el-input v-model="form.drop_reason" 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>
|
||||
</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 { createSubject, getSubject, updateSubject } from "../../api/subjects";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
const sites = ref<any[]>([]);
|
||||
|
||||
const subjectId = computed(() => route.params.subjectId as string | undefined);
|
||||
const isEdit = computed(() => !!subjectId.value);
|
||||
const studyId = computed(() => study.currentStudy?.id || "");
|
||||
|
||||
const form = reactive({
|
||||
subject_no: "",
|
||||
site_id: "",
|
||||
screening_date: "",
|
||||
status: "SCREENING",
|
||||
enrollment_date: "",
|
||||
completion_date: "",
|
||||
drop_reason: "",
|
||||
});
|
||||
|
||||
const loadSites = async () => {
|
||||
if (!studyId.value) return;
|
||||
try {
|
||||
const { data } = await fetchSites(studyId.value, { limit: 500 });
|
||||
sites.value = Array.isArray(data) ? data : data.items || [];
|
||||
} catch {
|
||||
sites.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!isEdit.value || !studyId.value || !subjectId.value) return;
|
||||
try {
|
||||
const { data } = await getSubject(studyId.value, subjectId.value);
|
||||
Object.assign(form, {
|
||||
subject_no: data.subject_no || "",
|
||||
site_id: data.site_id || "",
|
||||
screening_date: data.screening_date || "",
|
||||
status: data.status || "SCREENING",
|
||||
enrollment_date: data.enrollment_date || "",
|
||||
completion_date: data.completion_date || "",
|
||||
drop_reason: data.drop_reason || "",
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!studyId.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEdit.value && subjectId.value) {
|
||||
const payload = {
|
||||
status: form.status || null,
|
||||
enrollment_date: form.enrollment_date || null,
|
||||
completion_date: form.completion_date || null,
|
||||
drop_reason: form.drop_reason || null,
|
||||
};
|
||||
await updateSubject(studyId.value, subjectId.value, payload);
|
||||
ElMessage.success("已保存");
|
||||
router.push(`/subjects/${subjectId.value}`);
|
||||
} else {
|
||||
const payload = {
|
||||
subject_no: form.subject_no,
|
||||
site_id: form.site_id,
|
||||
screening_date: form.screening_date || null,
|
||||
};
|
||||
const { data } = await createSubject(studyId.value, payload);
|
||||
ElMessage.success("已创建");
|
||||
router.push(`/subjects/${data.id}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => router.push("/subjects");
|
||||
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
@@ -59,7 +59,7 @@
|
||||
<div class="quick-entry-card">
|
||||
<div class="entry-header">
|
||||
<span class="entry-title">快捷入口</span>
|
||||
<el-button type="primary" link @click="goMore('/study/home')" class="entry-more">
|
||||
<el-button type="primary" link @click="goMore('/project/overview')" class="entry-more">
|
||||
进入项目 <el-icon class="el-icon--right"><Right /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -96,9 +96,8 @@ import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { fetchAes } from "../../api/aes";
|
||||
import { fetchFinanceItems } from "../../api/finance";
|
||||
import { fetchImpTransactions } from "../../api/impTransactions";
|
||||
import { Right, Timer, UserFilled, Warning, Money, Collection, Management } from "@element-plus/icons-vue";
|
||||
import { listFinanceContracts } from "../../api/financeContracts";
|
||||
import { Right, Timer, UserFilled, Warning, Money, Collection } from "@element-plus/icons-vue";
|
||||
|
||||
interface WorkItem {
|
||||
title: string;
|
||||
@@ -132,26 +131,24 @@ const quickActions = computed(() => {
|
||||
if (!currentStudyId.value) return [];
|
||||
if (role.value === "CRA")
|
||||
return [
|
||||
{ label: "伦理启动", path: "/study/milestones", icon: Timer },
|
||||
{ label: "AE 列表", path: "/study/aes", icon: Warning },
|
||||
{ label: "立项与伦理", path: "/startup/feasibility-ethics", icon: Timer },
|
||||
{ label: "AE 列表", path: "/subjects", icon: Warning },
|
||||
];
|
||||
if (role.value === "PM")
|
||||
return [
|
||||
{ label: "伦理启动", path: "/study/milestones", icon: Timer },
|
||||
{ label: "费用审核", path: "/study/finance", icon: Money },
|
||||
{ label: "公告管理", path: "/study/faq", icon: Collection },
|
||||
{ label: "立项与伦理", path: "/startup/feasibility-ethics", icon: Timer },
|
||||
{ label: "合同费用", path: "/finance/contracts", icon: Money },
|
||||
{ label: "医学咨询", path: "/knowledge/medical-consult", icon: Collection },
|
||||
];
|
||||
if (role.value === "PV") return [{ label: "AE 列表", path: "/study/aes", icon: Warning }];
|
||||
if (role.value === "IMP") return [{ label: "药品台账", path: "/study/imp/transactions", icon: Management }];
|
||||
return [{ label: "伦理启动", path: "/study/milestones", icon: Timer }];
|
||||
if (role.value === "PV") return [{ label: "AE 列表", path: "/subjects", icon: Warning }];
|
||||
return [{ label: "立项与伦理", path: "/startup/feasibility-ethics", icon: Timer }];
|
||||
});
|
||||
|
||||
const todayMorePath = computed(() => (role.value === "IMP" ? "/study/imp/transactions" : "/study/aes"));
|
||||
const overdueMorePath = computed(() => (role.value === "IMP" ? "/study/imp/transactions" : "/study/aes"));
|
||||
const todayMorePath = computed(() => "/subjects");
|
||||
const overdueMorePath = computed(() => "/subjects");
|
||||
const actionMorePath = computed(() => {
|
||||
if (role.value === "IMP") return "/study/imp/transactions";
|
||||
if (role.value === "PM" || role.value === "ADMIN") return "/study/finance";
|
||||
return "/study/aes";
|
||||
if (role.value === "PM" || role.value === "ADMIN") return "/finance/contracts";
|
||||
return "/subjects";
|
||||
});
|
||||
|
||||
const isOverdue = (d?: string | null) => {
|
||||
@@ -164,23 +161,15 @@ const toAeItem = (ae: any): WorkItem => ({
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
status: ae.status,
|
||||
overdue: isOverdue(ae.expected_resolution_date || ae.updated_at),
|
||||
path: `/study/aes/${ae.id}`,
|
||||
path: ae.subject_id ? `/subjects/${ae.subject_id}` : "/subjects",
|
||||
});
|
||||
|
||||
const toFinanceItem = (fi: any): WorkItem => ({
|
||||
title: fi.title || "费用",
|
||||
const toContractItem = (fi: any): WorkItem => ({
|
||||
title: fi.contract_no || "合同费用",
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
status: fi.status,
|
||||
status: fi.site_name,
|
||||
overdue: false,
|
||||
path: `/study/finance/${fi.id}`,
|
||||
});
|
||||
|
||||
const toImpItem = (tx: any): WorkItem => ({
|
||||
title: tx.type || "药品记录",
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
status: tx.status || "",
|
||||
overdue: false,
|
||||
path: "/study/imp/transactions",
|
||||
path: `/finance/contracts/${fi.id}`,
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -189,17 +178,13 @@ const loadData = async () => {
|
||||
try {
|
||||
const studyId = currentStudyId.value;
|
||||
const aesReq = fetchAes(studyId, { status: "NEW", limit: 50 });
|
||||
const financeReq = fetchFinanceItems(studyId, { status: "SUBMITTED", limit: 50 });
|
||||
const impReq = fetchImpTransactions ? fetchImpTransactions(studyId, { limit: 50 }) : Promise.resolve({ data: [] });
|
||||
|
||||
const [aesRes, financeRes, impRes] = await Promise.allSettled([aesReq, financeReq, impReq]);
|
||||
const [aesRes, financeRes] = await Promise.allSettled([aesReq, listFinanceContracts(studyId, { limit: 50 })]);
|
||||
const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : [];
|
||||
const finances =
|
||||
financeRes.status === "fulfilled" ? (financeRes.value.data.items || financeRes.value.data || []) : [];
|
||||
const impData = impRes.status === "fulfilled" ? (impRes.value as any).data : [];
|
||||
const impTx = Array.isArray(impData) ? impData : (impData?.items || []);
|
||||
|
||||
buildSections(aes, finances, impTx);
|
||||
buildSections(aes, finances);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "工作台数据加载失败");
|
||||
} finally {
|
||||
@@ -207,13 +192,13 @@ const loadData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildSections = (aes: any[], finances: any[], impTx: any[]) => {
|
||||
const buildSections = (aes: any[], finances: any[]) => {
|
||||
todayList.value = [];
|
||||
overdueList.value = [];
|
||||
actionList.value = [];
|
||||
|
||||
const openAes = aes.filter((a) => a.status !== "CLOSED");
|
||||
const financePending = finances.filter((f) => f.status !== "PAID");
|
||||
const financePending = finances;
|
||||
const overdueAes = openAes.filter((a) => isOverdue(a.expected_resolution_date || a.updated_at));
|
||||
|
||||
if (role.value === "CRA") {
|
||||
@@ -223,17 +208,13 @@ const buildSections = (aes: any[], finances: any[], impTx: any[]) => {
|
||||
} else if (role.value === "PM" || role.value === "ADMIN") {
|
||||
todayList.value = openAes.slice(0, 5).map(toAeItem);
|
||||
overdueList.value = overdueAes.slice(0, 5).map(toAeItem);
|
||||
const financeTodo = financePending.slice(0, 3).map(toFinanceItem);
|
||||
const financeTodo = financePending.slice(0, 3).map(toContractItem);
|
||||
actionList.value = [...financeTodo, ...openAes.slice(0, 2).map(toAeItem)].slice(0, 5);
|
||||
} else if (role.value === "PV") {
|
||||
const aeOpen = aes.filter((a) => a.status !== "CLOSED");
|
||||
todayList.value = aeOpen.slice(0, 5).map(toAeItem);
|
||||
overdueList.value = aeOpen.filter((a) => isOverdue(a.updated_at)).slice(0, 5).map(toAeItem);
|
||||
actionList.value = aeOpen.slice(0, 5).map(toAeItem);
|
||||
} else if (role.value === "IMP") {
|
||||
actionList.value = impTx.slice(0, 5).map(toImpItem);
|
||||
todayList.value = [];
|
||||
overdueList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user