修复前后端显示异常问题(受试者、中心、负责人、日期等)

This commit is contained in:
Cheng Zhou
2025-12-18 22:09:28 +08:00
parent fb4950f8f7
commit 7137665410
48 changed files with 764 additions and 156 deletions
+28 -4
View File
@@ -14,9 +14,9 @@
</div>
</div>
<el-timeline>
<el-timeline-item v-for="c in comments" :key="c.id" :timestamp="c.created_at">
<el-timeline-item v-for="c in comments" :key="c.id" :timestamp="displayDateTime(c.created_at)">
<div class="comment-item">
<strong>{{ c.created_by }}</strong>
<strong>{{ displayUser(c.created_by, { users: userMap, members: memberMap }) }}</strong>
<p>{{ c.content }}</p>
</div>
</el-timeline-item>
@@ -25,10 +25,12 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchComments, createComment } from "../api/comments";
import { useStudyStore } from "../store/study";
import { listMembers } from "../api/members";
import { displayDateTime, displayUser } from "../utils/display";
interface Props {
studyId: string;
@@ -43,6 +45,7 @@ const loading = ref(false);
const newComment = ref("");
const showInput = ref(false);
const study = useStudyStore();
const members = ref<any[]>([]);
const load = async () => {
if (!props.studyId) return;
@@ -57,6 +60,24 @@ const load = async () => {
}
};
const loadMembers = async () => {
if (!props.studyId) return;
try {
const { data } = await listMembers(props.studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {})
);
const submit = async () => {
if (!newComment.value.trim()) {
ElMessage.warning("请输入评论");
@@ -77,7 +98,10 @@ const submit = async () => {
}
};
onMounted(() => load());
onMounted(() => {
loadMembers();
load();
});
</script>
<style scoped>
+4 -1
View File
@@ -12,7 +12,9 @@
<el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" />
<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="180">
<template #default="scope">
<el-button type="text" size="small" @click="view(scope.row)">查看</el-button>
@@ -41,6 +43,7 @@ import { useRouter } from "vue-router";
import { updateFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs";
import PermissionAction from "./PermissionAction.vue";
import { displayDateTime } from "../utils/display";
const props = defineProps<{
items: FaqItem[];
+20 -16
View File
@@ -11,7 +11,7 @@
</el-form-item>
<el-form-item label="中心" prop="site_id">
<el-select v-model="form.site_id" placeholder="请选择中心" filterable>
<el-option v-for="s in sites" :key="s.id" :label="s.name || s.id" :value="s.id" />
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="负责人" prop="owner_id">
@@ -54,7 +54,6 @@ interface Props {
milestone?: Record<string, any>;
sites?: any[];
members?: any[];
users?: any[];
}
const props = defineProps<Props>();
@@ -94,23 +93,28 @@ const submitting = ref(false);
const isEdit = computed(() => !!props.milestone);
const memberOptions = computed(() => {
const userMap = (props.users || []).reduce<Record<string, string>>((acc, cur: any) => {
if (cur?.id) acc[cur.id] = cur.username || cur.id;
return acc;
}, {});
const memberList = (props.members || []).filter((m: any) => m.is_active !== false);
if (memberList.length) {
return memberList.map((m: any) => ({
value: m.user_id,
label: userMap[m.user_id] || m.username || m.user_id,
}));
}
return (props.users || []).map((u: any) => ({
value: u.id,
label: u.username || u.id,
}));
const seen = new Set<string>();
return memberList
.map((m: any) => {
const user = m.user || {};
const label = user.display_name || user.username || m.username || "—";
return { value: m.user_id, label };
})
.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
});
const siteOptions = computed(() =>
(props.sites || []).map((s: any) => ({
value: s.id,
label: s.name || "—",
}))
);
watch(
() => props.milestone,
(val) => {
+2 -4
View File
@@ -64,10 +64,9 @@ const onClose = () => {
const resetForm = () => {
const studyId = study.currentStudy?.id;
const recentSite = studyId ? localStorage.getItem(`recent_site_${studyId}`) : null;
const recentSubject = studyId ? localStorage.getItem(`recent_subject_${studyId}`) : null;
form.site_id = recentSite || "";
form.subject_no = recentSubject || "";
form.screening_date = new Date().toISOString().slice(0, 10);
form.subject_no = "";
form.screening_date = "";
};
const onSubmit = async () => {
@@ -84,7 +83,6 @@ const onSubmit = async () => {
ElMessage.success("提交成功");
if (study.currentStudy?.id) {
localStorage.setItem(`recent_site_${study.currentStudy.id}`, form.site_id || "");
if (data?.id) localStorage.setItem(`recent_subject_${study.currentStudy.id}`, data.id);
}
emit("success");
if (keepCreating.value) {
+18 -2
View File
@@ -13,7 +13,9 @@
</el-select>
</el-form-item>
<el-form-item label="负责人" prop="assignee_id">
<UserSelect v-model="form.assignee_id" placeholder="选择负责人" />
<el-select v-model="form.assignee_id" placeholder="选择负责人" filterable clearable>
<el-option v-for="m in assigneeOptions" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-select v-model="form.priority" placeholder="请选择">
@@ -42,12 +44,12 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createTask, updateTask } from "../api/tasks";
import { useStudyStore } from "../store/study";
import UserSelect from "./selectors/UserSelect.vue";
interface Props {
modelValue: boolean;
task?: Record<string, any>;
milestones: any[];
members?: any[];
}
const props = defineProps<Props>();
@@ -89,6 +91,20 @@ const rules: FormRules = {
const submitting = ref(false);
const isEdit = computed(() => !!props.task);
const assigneeOptions = computed(() => {
const seen = new Set<string>();
return (props.members || [])
.filter((m: any) => m.is_active !== false)
.map((m: any) => {
const user = m.user || {};
return { value: m.user_id, label: user.display_name || user.username || m.username || "—" };
})
.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
});
watch(
() => props.task,
+17 -3
View File
@@ -1,14 +1,24 @@
<template>
<el-table :data="verifications" v-loading="loading" style="width: 100%">
<el-table-column prop="subject_id" label="受试者ID" />
<el-table-column prop="subject_id" label="受试者">
<template #default="scope">
{{ subjectMap[scope.row.subject_id] || scope.row.subject_no || scope.row.subject_id || "-" }}
</template>
</el-table-column>
<el-table-column prop="level" label="类型" width="100" />
<el-table-column label="完成度" width="180">
<template #default="scope">
<el-progress :percentage="scope.row.percent || 0" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column prop="last_verified_at" label="最近核查" width="140" />
<el-table-column prop="verifier_id" label="核查人" width="160" />
<el-table-column prop="last_verified_at" label="最近核查" width="140">
<template #default="scope">{{ displayDate(scope.row.last_verified_at) }}</template>
</el-table-column>
<el-table-column prop="verifier_id" label="核查人" width="160">
<template #default="scope">
{{ verifierMap[scope.row.verifier_id] || "-" }}
</template>
</el-table-column>
<el-table-column v-if="canEdit" label="操作" width="120">
<template #default="scope">
<el-button type="primary" link size="small" @click="$emit('edit', scope.row)">更新进度</el-button>
@@ -18,10 +28,14 @@
</template>
<script setup lang="ts">
import { displayDate } from "../utils/display";
defineProps<{
verifications: any[];
canEdit: boolean;
loading: boolean;
subjectMap: Record<string, string>;
verifierMap: Record<string, string>;
}>();
defineEmits(["edit"]);
</script>
@@ -12,14 +12,18 @@
<el-table :data="attachments" v-loading="loading" style="width: 100%">
<el-table-column prop="filename" label="文件名" min-width="200" />
<el-table-column label="大小" width="120">
<template #default="scope">{{ formatFileSize(scope.row.file_size) }}</template>
<template #default="scope">{{ formatFileSize(scope.row.file_size ?? scope.row.size) }}</template>
</el-table-column>
<el-table-column label="上传人" width="180">
<template #default="scope">
{{ uploaderLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column prop="uploaded_at" label="上传时间" width="180" />
<el-table-column prop="uploaded_at" label="上传时间" width="180">
<template #default="scope">
{{ displayDateTime(scope.row.uploaded_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button link type="primary" size="small" @click="download(scope.row)">下载</el-button>
@@ -46,8 +50,8 @@ import AttachmentUploader from "./AttachmentUploader.vue";
import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
import { displayDateTime, displayUser } from "../../utils/display";
const props = defineProps<{
studyId: string;
@@ -59,7 +63,6 @@ const attachments = ref<any[]>([]);
const loading = ref(false);
const auth = useAuthStore();
const study = useStudyStore();
const users = ref<any[]>([]);
const members = ref<any[]>([]);
const load = async () => {
@@ -75,15 +78,6 @@ const load = async () => {
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const loadMembers = async () => {
if (!props.studyId) return;
try {
@@ -95,15 +89,15 @@ const loadMembers = async () => {
};
const uploaderLabel = (row: any) => {
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
if (cur?.id) acc[cur.id] = cur.username || cur.id;
return acc;
}, {});
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
if (cur?.user_id) acc[cur.user_id] = cur.username || cur.user_id;
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {});
return userMap[row.uploaded_by] || memberMap[row.uploaded_by] || row.uploaded_by || "-";
if (row?.uploaded_by && typeof row.uploaded_by === "object") {
return row.uploaded_by.display_name || row.uploaded_by.username || row.uploaded_by.id || "—";
}
return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap });
};
const download = (row: any) => {
@@ -116,7 +110,9 @@ const canDelete = (row: any) => {
const userId = auth.user?.id;
const role = auth.user?.role;
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
return userId === row.uploaded_by || role === "ADMIN" || projectRole === "PM";
const ownerId =
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
return userId === ownerId || role === "ADMIN" || projectRole === "PM";
};
const remove = async (row: any) => {
@@ -132,7 +128,7 @@ const remove = async (row: any) => {
};
onMounted(async () => {
await Promise.all([loadUsers(), loadMembers()]);
await Promise.all([loadMembers()]);
load();
});
</script>
+45
View File
@@ -0,0 +1,45 @@
/**
* 统一的显示适配层,避免直接暴露 ID / 枚举原值 / 原始时间。
*/
export const displayFallback = "—";
export const displayEnum = (enumMap: Record<string, string>, value?: string | null) => {
if (!value) return displayFallback;
return enumMap[value] || displayFallback;
};
export const displayUser = (
userId?: string | null,
opts?: { users?: Record<string, string>; members?: Record<string, string> }
) => {
if (!userId) return displayFallback;
const name =
opts?.users?.[userId] ||
opts?.members?.[userId] ||
// 兼容成员数据的 user_id 为 key 的情况
(opts?.members && Object.values(opts.members).find((_, k) => k === userId));
return (name as string) || displayFallback;
};
export const displayEntity = (entityMap: Record<string, string>, id?: string | null) => {
if (!id) return displayFallback;
return entityMap[id] || displayFallback;
};
export const displayDate = (value?: string | number | Date | null) => {
if (!value) return displayFallback;
const date = new Date(value);
if (isNaN(date.getTime())) return displayFallback;
return date.toISOString().slice(0, 10);
};
export const displayDateTime = (value?: string | number | Date | null) => {
if (!value) return displayFallback;
const date = new Date(value);
if (isNaN(date.getTime())) return displayFallback;
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
date.getMinutes()
)}`;
};
+28 -1
View File
@@ -21,7 +21,7 @@
</div>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="受试者ID">{{ ae.subject_id }}</el-descriptions-item>
<el-descriptions-item label="受试者">{{ subjectName(ae.subject_id) }}</el-descriptions-item>
<el-descriptions-item label="严重性">{{ ae.seriousness }}</el-descriptions-item>
<el-descriptions-item label="严重程度">{{ ae.severity }}</el-descriptions-item>
<el-descriptions-item label="发生日期">{{ ae.onset_date }}</el-descriptions-item>
@@ -56,6 +56,7 @@ import { computed, onMounted, ref } 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 { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
@@ -74,6 +75,7 @@ const auth = useAuthStore();
const ae = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const subjects = ref<any[]>([]);
const { can } = usePermission();
const aeState = computed(() => (ae.value?.status as string) || "NEW");
@@ -102,6 +104,30 @@ const load = async () => {
}
};
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 subjectName = (id?: string | null) => {
if (!id) return "—";
return subjectLabel.value[id] || id;
};
const onAction = async (action: ActionConfig) => {
if (!study.currentStudy || !ae.value) return;
const prevStatus = ae.value.status;
@@ -167,6 +193,7 @@ onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSubjects();
load();
});
</script>
+13 -4
View File
@@ -44,15 +44,23 @@
{{ formatAmount(scope.row.amount) }}
</template>
</el-table-column>
<el-table-column prop="occur_date" label="发生日期" width="140" />
<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="160" />
<el-table-column prop="approved_at" label="审批时间" width="160" />
<el-table-column prop="paid_at" label="支付时间" width="160" />
<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
@@ -102,6 +110,7 @@ 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();
+54 -6
View File
@@ -14,14 +14,14 @@
</div>
</div>
<el-descriptions :column="2" border class="mt-12" v-if="item">
<el-descriptions-item label="类别">{{ item.category }}</el-descriptions-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="发生日期">{{ item.occur_date }}</el-descriptions-item>
<el-descriptions-item label="中心">{{ item.site_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="受试者">{{ item.subject_id || "-" }}</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="状态时间">
提交: {{ item.submitted_at || "-" }} / 审批: {{ item.approved_at || "-" }} /
支付: {{ item.paid_at || "-" }}
提交: {{ 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>
@@ -50,11 +50,14 @@ 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();
@@ -63,6 +66,8 @@ 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);
@@ -84,8 +89,50 @@ const loadItem = async () => {
}
};
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);
@@ -96,6 +143,7 @@ onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSites(), loadSubjects()]);
await loadItem();
});
</script>
+58 -6
View File
@@ -2,8 +2,17 @@
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-input v-model="filters.site_id" placeholder="中心ID" clearable @change="load" style="width: 200px" />
<el-select v-model="filters.product_id" placeholder="产品" clearable @change="load">
<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" />
@@ -12,10 +21,16 @@
</el-card>
<el-card>
<el-table :data="rows" v-loading="loading" style="width: 100%">
<el-table-column prop="site_id" label="中心" />
<el-table-column prop="batch_id" label="批次" />
<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" />
<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>
@@ -26,8 +41,11 @@ 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();
@@ -36,6 +54,8 @@ 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;
@@ -47,6 +67,26 @@ const loadProducts = async () => {
}
};
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;
@@ -67,11 +107,23 @@ const load = async () => {
}
};
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 loadProducts();
await Promise.all([loadProducts(), loadBatches(), loadSites()]);
load();
});
</script>
+78 -7
View File
@@ -2,8 +2,26 @@
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-input v-model="filters.site_id" placeholder="中心ID" clearable @change="load" style="width: 160px" />
<el-input v-model="filters.batch_id" placeholder="批次ID" clearable @change="load" style="width: 160px" />
<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>
@@ -29,15 +47,23 @@
</el-card>
<el-card>
<el-table :data="txs" v-loading="loading" style="width: 100%">
<el-table-column prop="tx_date" label="日期" width="120" />
<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" />
<el-table-column prop="batch_id" label="批次" width="180" />
<el-table-column prop="subject_id" label="受试者" width="180" />
<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">
@@ -71,6 +97,8 @@ 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";
@@ -78,6 +106,7 @@ 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();
@@ -91,6 +120,8 @@ 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"));
@@ -105,6 +136,26 @@ const loadBatches = async () => {
}
};
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;
@@ -171,11 +222,31 @@ const confirmTx = async (row: any) => {
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 loadBatches();
await Promise.all([loadBatches(), loadSites(), loadSubjects()]);
load();
});
</script>
+26 -19
View File
@@ -12,8 +12,8 @@
<el-descriptions-item label="类型">{{ typeLabel(milestone.type) }}</el-descriptions-item>
<el-descriptions-item label="中心">{{ siteName(milestone) }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ ownerName(milestone.owner_id) }}</el-descriptions-item>
<el-descriptions-item label="计划日期">{{ milestone.planned_date || "—" }}</el-descriptions-item>
<el-descriptions-item label="实际日期">{{ milestone.actual_date || "—" }}</el-descriptions-item>
<el-descriptions-item label="计划日期">{{ displayDate(milestone.planned_date) }}</el-descriptions-item>
<el-descriptions-item label="实际日期">{{ displayDate(milestone.actual_date) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="statusColor(milestone.status)">{{ statusLabel(milestone.status) }}</el-tag>
</el-descriptions-item>
@@ -32,10 +32,14 @@
/>
</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="milestones"
entity-type="ethics_node"
:entity-id="milestone.id"
/>
</el-tab-pane>
@@ -53,7 +57,6 @@ import { ElMessage } from "element-plus";
import { fetchMilestones } from "../api/milestones";
import { fetchSites } from "../api/sites";
import { listMembers } from "../api/members";
import { fetchUsers } from "../api/users";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
@@ -63,6 +66,7 @@ import {
getMilestoneStatusLabel,
getMilestoneTypeLabel,
} from "../dictionaries/milestone.dict";
import { displayDate, displayUser } from "../utils/display";
const route = useRoute();
const study = useStudyStore();
@@ -72,7 +76,6 @@ const milestone = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const sites = ref<any[]>([]);
const members = ref<any[]>([]);
const users = ref<any[]>([]);
const loadMilestone = async () => {
if (!study.currentStudy) return;
@@ -105,37 +108,32 @@ const loadMembers = async () => {
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const typeLabel = (v: string) => getMilestoneTypeLabel(v);
const statusLabel = (v: string) => getMilestoneStatusLabel(v);
const statusColor = (v: string) => getMilestoneStatusColor(v);
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) => {
if (!ownerId) return "未指定";
const member = members.value.find((m: any) => m.user_id === ownerId);
const user = users.value.find((u: any) => u.id === ownerId);
return user?.username || member?.username || ownerId || "未指定";
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {});
const owner = milestone.value?.owner;
return owner?.display_name || owner?.username || displayUser(ownerId, { members: memberMap });
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSites(), loadMembers(), loadUsers(), loadMilestone()]);
await Promise.all([loadSites(), loadMembers(), loadMilestone()]);
});
</script>
@@ -155,4 +153,13 @@ onMounted(async () => {
.mb-12 {
margin-bottom: 12px;
}
.tip {
margin-bottom: 8px;
color: #666;
line-height: 1.4;
}
.sub-tip {
color: #888;
font-size: 12px;
}
</style>
+19 -20
View File
@@ -33,12 +33,12 @@
</el-table-column>
<el-table-column label="计划日期" width="140">
<template #default="scope">
{{ scope.row.planned_date || "—" }}
{{ displayDate(scope.row.planned_date) }}
</template>
</el-table-column>
<el-table-column label="实际日期" width="140">
<template #default="scope">
{{ scope.row.actual_date || "—" }}
{{ displayDate(scope.row.actual_date) }}
</template>
</el-table-column>
<el-table-column label="状态" width="140">
@@ -63,20 +63,18 @@
:milestone="editingMilestone"
:sites="sites"
:members="members"
:users="users"
@success="loadMilestones"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchMilestones } from "../api/milestones";
import { fetchSites } from "../api/sites";
import { listMembers } from "../api/members";
import { fetchUsers } from "../api/users";
import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue";
import MilestoneForm from "../components/MilestoneForm.vue";
@@ -85,6 +83,7 @@ import {
getMilestoneStatusLabel,
getMilestoneTypeLabel,
} from "../dictionaries/milestone.dict";
import { displayDate, displayUser } from "../utils/display";
const study = useStudyStore();
const router = useRouter();
@@ -92,7 +91,6 @@ const router = useRouter();
const milestones = ref<any[]>([]);
const sites = ref<any[]>([]);
const members = ref<any[]>([]);
const users = ref<any[]>([]);
const loading = ref(false);
const showForm = ref(false);
const editingMilestone = ref<any | null>(null);
@@ -130,15 +128,6 @@ const loadMembers = async () => {
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const openCreate = () => {
editingMilestone.value = null;
showForm.value = true;
@@ -153,24 +142,34 @@ onMounted(() => {
loadMilestones();
loadSites();
loadMembers();
loadUsers();
});
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 = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
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) => {
if (!ownerId) return "未指定";
const member = members.value.find((m: any) => m.user_id === ownerId);
const user = users.value.find((u: any) => u.id === ownerId);
return user?.username || member?.username || ownerId || "未指定";
const fromPayload = milestones.value.find((m) => m.owner_id === ownerId)?.owner;
return (
fromPayload?.display_name ||
fromPayload?.username ||
displayUser(ownerId, { members: memberMap.value })
);
};
const goDetail = (row: any) => {
+20 -1
View File
@@ -2,7 +2,7 @@
<div class="page" v-if="subject">
<el-card class="mb-12">
<h3>受试者 {{ subject.subject_no }}</h3>
<p>中心{{ subject.site_id }}</p>
<p>中心{{ siteName(subject.site_id) }}</p>
<p>
状态
<el-tag :type="stateColor(subject.status)">{{ stateLabel(subject.status) }}</el-tag>
@@ -35,6 +35,7 @@ import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
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";
@@ -52,6 +53,7 @@ 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"));
@@ -76,6 +78,22 @@ const loadSubject = async () => {
}
};
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 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 {
@@ -143,6 +161,7 @@ onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSites();
await loadSubject();
await loadVisits();
});
+43 -22
View File
@@ -8,7 +8,16 @@
<el-select v-model="filters.milestone_id" placeholder="里程碑" clearable @change="loadTasks">
<el-option v-for="m in milestones" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
<el-input v-model="filters.assignee_id" placeholder="负责人" style="width: 180px" @change="loadTasks" />
<el-select
v-model="filters.assignee_id"
placeholder="负责人"
clearable
filterable
style="width: 200px"
@change="loadTasks"
>
<el-option v-for="opt in assigneeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<div class="spacer" />
<PermissionAction action="task.create">
<el-button type="primary" @click="openCreate">新建任务</el-button>
@@ -66,7 +75,13 @@
/>
</el-card>
<TaskForm v-model="showForm" :task="editingTask" :milestones="milestones" @success="loadTasks" />
<TaskForm
v-model="showForm"
:task="editingTask"
:milestones="milestones"
:members="members"
@success="loadTasks"
/>
</div>
</template>
@@ -76,7 +91,6 @@ import { ElMessage } from "element-plus";
import { fetchTasks, updateTask } from "../api/tasks";
import { fetchMilestones } from "../api/milestones";
import { listMembers } from "../api/members";
import { fetchUsers } from "../api/users";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
@@ -93,7 +107,6 @@ const auth = useAuthStore();
const tasks = ref<any[]>([]);
const milestones = ref<any[]>([]);
const members = ref<any[]>([]);
const users = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
@@ -119,16 +132,25 @@ const milestoneMap = computed(() =>
);
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.user_id] = cur.username || cur.user_id;
return acc;
}, {})
);
const userMap = computed(() =>
users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.username || cur.id;
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {})
);
const assigneeOptions = computed(() => {
const seen = new Set<string>();
return members.value
.filter((m) => m.is_active !== false)
.map((m) => {
const user = m.user || {};
return { value: m.user_id, label: user.display_name || user.username || m.username || "—" };
})
.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
});
const loadMilestones = async () => {
if (!study.currentStudy) return;
@@ -150,15 +172,6 @@ const loadMembers = async () => {
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const loadTasks = async () => {
if (!study.currentStudy) return;
loading.value = true;
@@ -238,7 +251,16 @@ const priorityLabel = (v: string) => getDictLabel(priorityDict, v);
const priorityColor = (v: string) => getDictColor(priorityDict, v) || "info";
const milestoneName = (id: string) => milestoneMap.value[id] || id || "-";
const assigneeName = (id: string) => userMap.value[id] || memberMap.value[id] || id || "-";
const assigneeName = (id: string) => {
const fromPayload = tasks.value.find((t) => t.assignee_id === id)?.assignee;
return (
fromPayload?.display_name ||
fromPayload?.username ||
memberMap.value[id] ||
id ||
"-"
);
};
const showComplete = (row: any) => {
const actions = getAvailableActions(taskMachine, row.status);
@@ -303,7 +325,6 @@ onMounted(async () => {
loadSavedFilters();
loadMilestones();
loadMembers();
loadUsers();
loadTasks();
});
</script>
+49 -1
View File
@@ -18,7 +18,14 @@
</div>
</el-card>
<VerificationTable :verifications="verifications" :can-edit="canEdit" :loading="loading" @edit="openEdit" />
<VerificationTable
:verifications="verifications"
:can-edit="canEdit"
:loading="loading"
:subject-map="subjectMap"
:verifier-map="verifierMap"
@edit="openEdit"
/>
<el-dialog :title="editForm.id ? '更新核查进度' : '新增核查进度'" v-model="showEdit" width="520px">
<el-form :model="editForm" label-width="120px">
@@ -60,16 +67,21 @@
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 } 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);
@@ -144,10 +156,46 @@ const submitEdit = async () => {
}
};
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 = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {})
);
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await Promise.all([loadSubjects(), loadMembers()]);
load();
});
</script>
+8 -2
View File
@@ -44,14 +44,19 @@
</el-button>
</div>
<el-table :data="logs" v-loading="loading" style="width: 100%">
<el-table-column prop="timestamp" label="时间" width="180" />
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">{{ displayDateTime(scope.row.timestamp) }}</template>
</el-table-column>
<el-table-column prop="actorName" label="操作人" width="140" />
<el-table-column prop="actorRoleLabel" label="角色" width="120" />
<el-table-column prop="eventLabel" label="操作类型" min-width="160" />
<el-table-column prop="actionText" label="操作内容" min-width="180" />
<el-table-column label="操作对象" min-width="180">
<template #default="scope">
{{ scope.row.targetTypeLabel }}{{ scope.row.targetId }} {{ scope.row.targetName || "" }}
<span v-if="scope.row.targetTypeLabel">
{{ scope.row.targetTypeLabel }}{{ scope.row.targetName || "" }}
</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="变更详情" min-width="220">
@@ -94,6 +99,7 @@ import { useAuthStore } from "../../store/auth";
import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService";
import { logAudit } from "../../audit";
import { displayDateTime } from "../../utils/display";
const study = useStudyStore();
const auth = useAuthStore();