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

This commit is contained in:
Cheng Zhou
2026-01-09 11:19:04 +08:00
parent ba3cf95b8a
commit 9021c7fe2b
188 changed files with 7632 additions and 11658 deletions
-191
View File
@@ -1,191 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑 AE' : '新增 AE'" v-model="visible" width="600px" @close="onClose" class="ctms-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
<el-form-item label="受试者" prop="subject_id">
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可留空" />
</el-form-item>
<el-form-item label="事件描述" prop="term">
<el-input v-model="form.term" />
</el-form-item>
<el-form-item label="发生日期" prop="onset_date">
<el-date-picker
v-model="form.onset_date"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableFutureDate"
/>
</el-form-item>
<el-form-item label="SAE" prop="seriousness">
<el-select v-model="form.seriousness" placeholder="请选择">
<el-option label="是" value="SERIOUS" />
<el-option label="否" value="NON_SERIOUS" />
</el-select>
</el-form-item>
<el-form-item label="严重程度" prop="severity">
<el-select v-model="form.severity" placeholder="请选择">
<el-option v-for="s in severities" :key="s" :label="severityLabel(s)" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
<el-form-item label="转归结局" prop="outcome">
<el-select v-model="form.outcome" placeholder="可留空" clearable>
<el-option v-for="o in outcomes" :key="o" :label="o" :value="o" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="footer-right">
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createAe, updateAe } from "../api/aes";
import { useStudyStore } from "../store/study";
import SubjectSelect from "./selectors/SubjectSelect.vue";
const props = defineProps<{
modelValue: boolean;
ae?: Record<string, any>;
subjects?: any[];
}>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const severities = ["G1", "G2", "G3", "G4", "G5"];
const outcomes = ["痊愈", "好转", "持续", "恶化", "死亡", "其他"];
const severityLabel = (v: string) =>
({
G1: "I级",
G2: "II级",
G3: "III级",
G4: "IV级",
G5: "V级",
}[v] || v);
const form = reactive({
subject_id: "",
term: "",
onset_date: "",
seriousness: "NON_SERIOUS",
severity: "G1",
description: "",
outcome: "",
});
const rules: FormRules = {
term: [{ required: true, message: "请输入事件描述", trigger: "blur" }],
onset_date: [{ required: true, message: "请选择发生日期", trigger: "change" }],
seriousness: [{ required: true, message: "请选择严重性", trigger: "change" }],
severity: [{ required: true, message: "请选择严重程度", trigger: "change" }],
};
const isEdit = computed(() => !!props.ae);
watch(
() => props.ae,
(val) => {
if (val) {
Object.assign(form, {
subject_id: val.subject_id || "",
term: val.term || "",
onset_date: val.onset_date || "",
seriousness: val.seriousness || "NON_SERIOUS",
severity: val.severity || "G1",
description: val.description || "",
outcome: val.outcome || "",
});
} else {
Object.assign(form, {
subject_id: "",
term: "",
onset_date: "",
seriousness: "NON_SERIOUS",
severity: "G1",
description: "",
outcome: "",
});
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const disableFutureDate = (time: Date) => time.getTime() > Date.now();
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (form.onset_date && new Date(form.onset_date) > new Date()) {
ElMessage.warning("发生日期不得晚于当前日期");
return;
}
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload: Record<string, any> = {};
Object.entries(form).forEach(([k, v]) => {
if (v !== "" && v !== null && v !== undefined) {
payload[k] = v;
}
});
if (form.outcome === "" || form.outcome === null || form.outcome === undefined) {
payload.outcome = null;
}
if (isEdit.value && props.ae) {
await updateAe(study.currentStudy.id, props.ae.id, payload);
} else {
await createAe(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 6px;
}
:deep(.el-dialog__body) {
padding-top: 12px;
}
:deep(.el-dialog__footer) {
display: flex;
justify-content: flex-end;
align-items: center;
}
</style>
@@ -1,84 +0,0 @@
<template>
<el-card class="attachment-card">
<template #header>
<div class="header">
<span>附件</span>
<el-upload
v-if="canUpload"
:http-request="onUpload"
:show-file-list="false"
:auto-upload="false"
:limit="1"
>
<el-button size="small" type="primary">上传</el-button>
</el-upload>
</div>
</template>
<el-table :data="attachments" v-loading="loading" style="width: 100%">
<el-table-column prop="filename" label="文件名" />
<el-table-column prop="uploaded_by" label="上传人" width="160" />
<el-table-column prop="uploaded_at" label="上传时间" width="180" />
<el-table-column label="操作" width="120">
<template #default="scope">
<el-link type="primary" :href="scope.row.file_path" target="_blank">下载</el-link>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchAttachments, uploadAttachment } from "../api/attachments";
import { useStudyStore } from "../store/study";
interface Props {
studyId: string;
entityType: string;
entityId: string;
canUpload?: boolean;
}
const props = defineProps<Props>();
const study = useStudyStore();
const attachments = ref<any[]>([]);
const loading = ref(false);
const load = async () => {
if (!props.studyId) return;
loading.value = true;
try {
const { data } = await fetchAttachments(props.studyId, props.entityType, props.entityId);
attachments.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "附件加载失败");
} finally {
loading.value = false;
}
};
const onUpload = async (options: any) => {
if (!study.currentStudy) return;
loading.value = true;
try {
await uploadAttachment(study.currentStudy.id, props.entityType, props.entityId, options.file as File);
ElMessage.success("上传成功");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "上传失败");
} finally {
loading.value = false;
}
};
onMounted(() => load());
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
-204
View File
@@ -1,204 +0,0 @@
<template>
<el-card class="comment-card">
<template #header>
<div class="header">
<span>评论</span>
<el-button v-if="canComment" type="primary" size="small" @click="showInput = !showInput">新增</el-button>
</div>
</template>
<ThreadComposer
v-if="showInput"
v-model="newComment"
v-model:file-list="fileList"
:submitting="loading || uploading"
:quote-item="quoteComment"
:member-map="memberMap"
:user-map="userMap"
:allow-attachments="true"
show-cancel
@submit="submit"
@cancel="showInput = false"
@clear-quote="clearQuote"
/>
<ThreadList
:items="comments"
:attachments-map="attachmentsMap"
:member-map="memberMap"
:user-map="userMap"
:can-quote="canComment"
:can-delete="canDelete"
@quote="setQuote"
@delete="remove"
/>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import type { UploadUserFile } from "element-plus";
import { fetchComments, createComment, deleteComment } from "../api/comments";
import { fetchAttachments, uploadAttachment } from "../api/attachments";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import { listMembers } from "../api/members";
import { getMemberDisplayName } from "../utils/display";
import ThreadComposer from "./ThreadComposer.vue";
import ThreadList from "./ThreadList.vue";
interface Props {
studyId: string;
entityType: string;
entityId: string;
canComment?: boolean;
}
const props = defineProps<Props>();
const comments = ref<any[]>([]);
const loading = ref(false);
const newComment = ref("");
const showInput = ref(false);
const study = useStudyStore();
const auth = useAuthStore();
const members = ref<any[]>([]);
const quoteComment = ref<any | null>(null);
const fileList = ref<UploadUserFile[]>([]);
const attachmentsMap = ref<Record<string, any[]>>({});
const uploading = ref(false);
const load = async () => {
if (!props.studyId) return;
loading.value = true;
try {
const { data } = await fetchComments(props.studyId, props.entityType, props.entityId);
comments.value = data.items || data || [];
await loadAttachments();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "评论加载失败");
} finally {
loading.value = false;
}
};
const loadMembers = async () => {
if (!props.studyId) return;
try {
const { data } = await listMembers(props.studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
return acc;
}, {})
);
const userMap = computed(() => {
const map: Record<string, string> = {};
if (auth.user?.id) {
map[auth.user.id] = auth.user.display_name || auth.user.username || auth.user.email || auth.user.id;
}
return map;
});
const submit = async () => {
if (!newComment.value.trim()) {
ElMessage.warning("请输入评论");
return;
}
if (!study.currentStudy) return;
loading.value = true;
try {
const { data } = await createComment(study.currentStudy.id, props.entityType, props.entityId, {
content: newComment.value,
quote_comment_id: quoteComment.value?.id || null,
});
const created = data as any;
if (fileList.value.length && created?.id) {
uploading.value = true;
try {
for (const file of fileList.value) {
if (!file.raw) continue;
await uploadAttachment(study.currentStudy.id, "comments", created.id, file.raw as File);
}
} finally {
uploading.value = false;
}
}
ElMessage.success("提交成功");
newComment.value = "";
quoteComment.value = null;
fileList.value = [];
showInput.value = false;
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
loading.value = false;
}
};
const setQuote = (comment: any) => {
quoteComment.value = comment;
showInput.value = true;
};
const clearQuote = () => {
quoteComment.value = null;
};
const canDelete = (comment: any) => auth.user?.role === "ADMIN" || comment?.created_by === auth.user?.id;
const remove = async (comment: any) => {
if (!study.currentStudy || !comment?.id) return;
const ok = await ElMessageBox.confirm("确认删除该评论?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteComment(study.currentStudy.id, props.entityType, props.entityId, comment.id);
ElMessage.success("评论已删除");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
const loadAttachments = async () => {
if (!props.studyId || comments.value.length === 0) {
attachmentsMap.value = {};
return;
}
const entries = await Promise.all(
comments.value.map(async (c) => {
try {
const { data } = await fetchAttachments(props.studyId, "comments", c.id);
const items = (data as any).items || data || [];
return [c.id, items] as const;
} catch {
return [c.id, []] as const;
}
})
);
const next: Record<string, any[]> = {};
entries.forEach(([id, items]) => {
next[id] = items;
});
attachmentsMap.value = next;
};
onMounted(() => {
loadMembers();
load();
});
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
-164
View File
@@ -1,164 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑数据问题' : '新建数据问题'" v-model="visible" width="600px" @close="onClose" class="ctms-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="受试者" prop="subject_id">
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可选" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择">
<el-option label="缺失(MISSING)" value="MISSING" />
<el-option label="不一致(INCONSISTENT)" value="INCONSISTENT" />
<el-option label="超范围(OUT_OF_RANGE)" value="OUT_OF_RANGE" />
<el-option label="其他(OTHER)" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-select v-model="form.priority" placeholder="请选择">
<el-option label="低" value="LOW" />
<el-option label="中" value="MEDIUM" />
<el-option label="高" value="HIGH" />
</el-select>
</el-form-item>
<el-form-item label="指派给" prop="assigned_to">
<UserSelect v-model="form.assigned_to" placeholder="可选" />
</el-form-item>
<el-form-item label="截止日期" prop="due_date">
<el-date-picker v-model="form.due_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<div class="footer-right">
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createDataQuery, updateDataQuery } from "../api/dataQueries";
import { useStudyStore } from "../store/study";
import SubjectSelect from "./selectors/SubjectSelect.vue";
import UserSelect from "./selectors/UserSelect.vue";
const props = defineProps<{
modelValue: boolean;
query?: Record<string, any>;
}>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const isEdit = computed(() => !!props.query);
const form = reactive({
title: "",
subject_id: "",
category: "MISSING",
priority: "MEDIUM",
assigned_to: "",
due_date: "",
description: "",
});
const rules: FormRules = {
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
category: [{ required: true, message: "请选择类别", trigger: "change" }],
priority: [{ required: true, message: "请选择优先级", trigger: "change" }],
};
watch(
() => props.query,
(val) => {
if (val) {
Object.assign(form, {
title: val.title || "",
subject_id: val.subject_id || "",
category: val.category || "MISSING",
priority: val.priority || "MEDIUM",
assigned_to: val.assigned_to || "",
due_date: val.due_date || "",
description: val.description || "",
});
} else {
Object.assign(form, {
title: "",
subject_id: "",
category: "MISSING",
priority: "MEDIUM",
assigned_to: "",
due_date: "",
description: "",
});
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload: Record<string, any> = {};
Object.entries(form).forEach(([k, v]) => {
if (v !== "" && v !== null && v !== undefined) payload[k] = v;
});
if (isEdit.value && props.query) {
await updateDataQuery(study.currentStudy.id, props.query.id, payload);
} else {
await createDataQuery(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 6px;
}
:deep(.el-dialog__body) {
padding-top: 12px;
}
:deep(.el-dialog__footer) {
display: flex;
justify-content: flex-end;
align-items: center;
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑 FAQ' : '新建 FAQ'" width="640px" @close="close">
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑咨询' : '新建咨询'" width="640px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="请选择分类">
+3 -3
View File
@@ -74,7 +74,7 @@ const categoryMap = computed(() =>
);
const view = (row: FaqItem) => {
router.push(`/study/faq/${row.id}`);
router.push(`/knowledge/medical-consult/${row.id}`);
};
const onRowClick = (row: FaqItem, column: any, event: MouseEvent) => {
@@ -99,7 +99,7 @@ const canDelete = (row: FaqItem) => props.canEdit || row.created_by === auth.use
const toggle = async (row: FaqItem) => {
const target = !row.is_active;
const ok = await ElMessageBox.confirm(`确认${target ? "启用" : "停用"}此 FAQ?`, "提示").catch(() => null);
const ok = await ElMessageBox.confirm(`确认${target ? "启用" : "停用"}此咨询?`, "提示").catch(() => null);
if (!ok) return;
try {
await updateFaqItem(row.id, { is_active: target });
@@ -111,7 +111,7 @@ const toggle = async (row: FaqItem) => {
};
const remove = async (row: FaqItem) => {
const ok = await ElMessageBox.confirm(`确认删除 FAQ「${row.question}」?`, "提示").catch(() => null);
const ok = await ElMessageBox.confirm(`确认删除咨询「${row.question}」?`, "提示").catch(() => null);
if (!ok) return;
try {
await deleteFaqItem(row.id);
-147
View File
@@ -1,147 +0,0 @@
<template>
<el-dialog :model-value="modelValue" title="费用" width="520px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="金额" prop="amount">
<el-input-number v-model="form.amount" :min="0.01" :step="100" :precision="2" controls-position="right" />
</el-form-item>
<el-form-item label="币种">
<el-input v-model="form.currency" />
</el-form-item>
<el-form-item label="发生日期" prop="occur_date">
<el-date-picker v-model="form.occur_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="中心">
<SiteSelect v-model="form.site_id" :study-id="study.currentStudy?.id || null" placeholder="可选" />
</el-form-item>
<el-form-item label="受试者">
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createFinanceItem, updateFinanceItem } from "../api/finance";
import SiteSelect from "./selectors/SiteSelect.vue";
import SubjectSelect from "./selectors/SubjectSelect.vue";
const props = defineProps<{ modelValue: boolean; item?: any }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const categories = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"];
const categoryLabel = (v: string) =>
({
SITE_FEE: "中心费用",
SUBJECT_STIPEND: "受试者补贴",
TRAVEL: "差旅",
OTHER: "其他",
}[v] || v);
const form = reactive({
category: "SITE_FEE",
title: "",
description: "",
amount: 0,
currency: "CNY",
occur_date: "",
site_id: "",
subject_id: "",
});
const rules: FormRules = {
category: [{ required: true, message: "请选择类别", trigger: "change" }],
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
amount: [{ required: true, message: "请输入金额", trigger: "blur" }],
occur_date: [{ required: true, message: "请选择日期", trigger: "change" }],
};
const isEdit = computed(() => !!props.item);
const reset = () => {
form.category = "SITE_FEE";
form.title = "";
form.description = "";
form.amount = 0;
form.currency = "CNY";
form.occur_date = "";
form.site_id = "";
form.subject_id = "";
};
watch(
() => props.item,
(val) => {
if (val) {
form.category = val.category;
form.title = val.title;
form.description = val.description || "";
form.amount = Number(val.amount) || 0;
form.currency = val.currency || "CNY";
form.occur_date = val.occur_date;
form.site_id = val.site_id || "";
form.subject_id = val.subject_id || "";
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
if (form.amount <= 0) {
ElMessageBox.alert("金额必须大于 0");
return;
}
submitting.value = true;
try {
const payload = {
category: form.category,
title: form.title,
description: form.description || null,
amount: Number(form.amount),
currency: form.currency,
occur_date: form.occur_date,
site_id: form.site_id ? form.site_id : null,
subject_id: form.subject_id ? form.subject_id : null,
};
if (isEdit.value && props.item) {
await updateFinanceItem(study.currentStudy.id, props.item.id, payload);
} else {
await createFinanceItem(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
};
</script>
@@ -1,155 +0,0 @@
<template>
<div class="actions">
<template v-for="action in availableActions" :key="action.key">
<PermissionAction :action="permissionMap[action.key]">
<el-button
size="small"
:type="buttonType(action)"
:loading="loading"
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</PermissionAction>
</template>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, ref } from "vue";
import { changeFinanceStatus } from "../api/finance";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import PermissionAction from "./PermissionAction.vue";
import { financeMachine, getAvailableActions } from "../state-machine";
import type { ActionConfig } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const props = defineProps<{ item: any }>();
const emit = defineEmits(["success"]);
const study = useStudyStore();
const auth = useAuthStore();
const loading = ref(false);
const permissionMap: Record<string, string> = {
submit: "finance.create",
approve: "finance.approve",
reject: "finance.approve",
pay: "finance.pay",
};
const availableActions = computed(() => getAvailableActions(financeMachine, props.item?.status));
const buttonType = (action: ActionConfig) => {
if (action.danger) return "danger";
if (action.key === "approve") return "success";
if (action.key === "pay") return "warning";
return "primary";
};
const change = async (status: string, reject_reason?: string) => {
if (!study.currentStudy) return false;
loading.value = true;
try {
await changeFinanceStatus(study.currentStudy.id, props.item.id, { status, reject_reason });
ElMessage.success("状态已更新");
emit("success");
return true;
} catch (e: any) {
const msg = e?.response?.data?.detail || e?.response?.data?.message || "更新失败";
ElMessage.error(msg);
if (e?.response?.status === 403) {
logAudit("UNAUTHORIZED_ACTION_ATTEMPT", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: msg,
severity: "violation",
});
} else {
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: msg,
severity: "warning",
});
}
return false;
} finally {
loading.value = false;
}
};
const onAction = async (action: ActionConfig) => {
if (!props.item) return;
const prevStatus = props.item.status;
if (prevStatus === action.to) {
ElMessage.warning("当前状态不允许该操作");
logAudit("INVALID_STATE_TRANSITION_ATTEMPT", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: "warning",
});
return;
}
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap[action.key],
stateMachine: financeMachine,
currentState: props.item.status,
actionKey: action.key,
target: { id: props.item.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认执行该操作?", "提示", {
type: action.danger ? "warning" : "info",
}).catch(() => null);
if (!ok) return;
}
if (action.key === "reject") {
const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回审批", {
confirmButtonText: "确定",
cancelButtonText: "取消",
}).catch(() => null);
if (!reason || !reason.value) return;
const ok = await change(action.to, reason.value);
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: ok ? "normal" : "warning",
});
return;
}
const ok = await change(action.to);
logAudit("FINANCE_STATUS_CHANGED", {
targetId: props.item.id,
targetName: props.item.title || props.item.id,
before: { status: prevStatus },
after: { status: action.to },
severity: ok ? "normal" : "warning",
});
};
</script>
<style scoped>
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
@@ -1,65 +0,0 @@
<template>
<el-card>
<div class="summary">
<div class="item">
<div class="label">总金额(全部)</div>
<div class="value">¥{{ format(summary?.total_amount) }}</div>
</div>
<div class="item">
<div class="label">待审批金额</div>
<div class="value">¥{{ format(summary?.submitted_amount) }}</div>
</div>
<div class="item">
<div class="label">已审批金额</div>
<div class="value">¥{{ format(summary?.approved_amount) }}</div>
</div>
<div class="item">
<div class="label">已支付金额</div>
<div class="value">¥{{ format(summary?.paid_amount) }}</div>
</div>
<div class="item">
<div class="label">条目数</div>
<div class="value">{{ summary?.count_total ?? 0 }}</div>
</div>
<div class="item">
<div class="label">已支付条目</div>
<div class="value">{{ summary?.count_paid ?? 0 }}</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import type { FinanceSummary } from "../api/finance";
defineProps<{
summary?: FinanceSummary;
}>();
const format = (num?: number | string) => {
const n = Number(num);
return Number.isFinite(n) ? n.toFixed(2) : "0.00";
};
</script>
<style scoped>
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.item {
background: #f5f7fa;
border-radius: 6px;
padding: 12px;
}
.label {
color: #606266;
font-size: 12px;
}
.value {
font-weight: 600;
margin-top: 4px;
font-size: 16px;
}
</style>
-125
View File
@@ -1,125 +0,0 @@
<template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑批次' : '新增批次'" width="520px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="产品" prop="product_id">
<el-select v-model="form.product_id" placeholder="请选择">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="批号" prop="batch_no">
<el-input v-model="form.batch_no" />
</el-form-item>
<el-form-item label="失效日期">
<el-date-picker v-model="form.expiry_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="生产日期">
<el-date-picker v-model="form.manufacture_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" placeholder="请选择状态">
<el-option
v-for="s in statusOptions"
:key="s"
:label="`${statusLabel(s)}(${s})`"
:value="s"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createImpBatch, updateImpBatch } from "../api/impBatches";
const props = defineProps<{ modelValue: boolean; batch?: any; products: any[] }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const statusOptions = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"];
const form = reactive({
product_id: "",
batch_no: "",
expiry_date: "",
manufacture_date: "",
status: "ACTIVE",
});
const rules: FormRules = {
product_id: [{ required: true, message: "请选择产品", trigger: "change" }],
batch_no: [{ required: true, message: "请输入批号", trigger: "blur" }],
};
const isEdit = computed(() => !!props.batch);
const reset = () => {
form.product_id = "";
form.batch_no = "";
form.expiry_date = "";
form.manufacture_date = "";
form.status = "ACTIVE";
};
watch(
() => props.batch,
(val) => {
if (val) {
form.product_id = val.product_id;
form.batch_no = val.batch_no;
form.expiry_date = val.expiry_date || "";
form.manufacture_date = val.manufacture_date || "";
form.status = val.status || "ACTIVE";
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
if (isEdit.value && props.batch) {
const payload = {
status: form.status,
expiry_date: form.expiry_date || null,
manufacture_date: form.manufacture_date || null,
};
await updateImpBatch(study.currentStudy.id, props.batch.id, payload);
} else {
const payload = {
product_id: form.product_id,
batch_no: form.batch_no,
expiry_date: form.expiry_date || null,
manufacture_date: form.manufacture_date || null,
};
await createImpBatch(study.currentStudy.id, payload);
}
ElMessage.success("保存成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
const statusLabel = (v: string) =>
({ ACTIVE: "在库", QUARANTINED: "隔离", EXPIRED: "过期", CLOSED: "关闭" }[v] || v);
</script>
-106
View File
@@ -1,106 +0,0 @@
<template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑产品' : '新增产品'" width="520px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="剂型">
<el-input v-model="form.form" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="form.strength" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" rows="2" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createImpProduct, updateImpProduct } from "../api/impProducts";
const props = defineProps<{ modelValue: boolean; product?: any }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
name: "",
form: "",
strength: "",
unit: "",
description: "",
is_active: true,
});
const rules: FormRules = {
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
unit: [{ required: true, message: "请输入单位", trigger: "blur" }],
};
const isEdit = computed(() => !!props.product);
const reset = () => {
form.name = "";
form.form = "";
form.strength = "";
form.unit = "";
form.description = "";
form.is_active = true;
};
watch(
() => props.product,
(val) => {
if (val) {
form.name = val.name;
form.form = val.form || "";
form.strength = val.strength || "";
form.unit = val.unit || "";
form.description = val.description || "";
form.is_active = val.is_active;
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
if (isEdit.value && props.product) {
await updateImpProduct(study.currentStudy.id, props.product.id, form);
} else {
await createImpProduct(study.currentStudy.id, form);
}
ElMessage.success("保存成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
</script>
@@ -1,130 +0,0 @@
<template>
<el-dialog :model-value="modelValue" title="新增交易" width="560px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="中心" prop="site_id">
<SiteSelect v-model="form.site_id" :study-id="study.currentStudy?.id || null" />
</el-form-item>
<el-form-item label="批次" prop="batch_id">
<el-select v-model="form.batch_id" placeholder="请选择批次">
<el-option v-for="b in batches" :key="b.id" :label="b.batch_no" :value="b.id" />
</el-select>
</el-form-item>
<el-form-item label="交易类型" prop="tx_type">
<el-select v-model="form.tx_type" placeholder="请选择类型">
<el-option
v-for="t in txTypes"
:key="t"
:label="`${txTypeLabel(t)}(${t})`"
:value="t"
/>
</el-select>
</el-form-item>
<el-form-item label="数量" prop="quantity">
<el-input-number v-model="form.quantity" :min="1" :step="1" />
</el-form-item>
<el-form-item label="受试者" :required="form.tx_type === 'DISPENSE'">
<SubjectSelect
v-model="form.subject_id"
:study-id="study.currentStudy?.id || null"
placeholder="发放时必填"
/>
</el-form-item>
<el-form-item label="日期" prop="tx_date">
<el-date-picker v-model="form.tx_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="单号/备注">
<el-input v-model="form.reference" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="form.notes" type="textarea" rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { useStudyStore } from "../store/study";
import { createImpTransaction } from "../api/impTransactions";
import SiteSelect from "./selectors/SiteSelect.vue";
import SubjectSelect from "./selectors/SubjectSelect.vue";
const props = defineProps<{ modelValue: boolean; batches: any[] }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const txTypes = ["RECEIPT", "DISPENSE", "RETURN", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"];
const form = reactive({
site_id: "",
batch_id: "",
subject_id: "",
tx_type: "RECEIPT",
quantity: 1,
tx_date: "",
reference: "",
notes: "",
});
const rules: FormRules = {
site_id: [{ required: true, message: "请选择中心", trigger: "change" }],
batch_id: [{ required: true, message: "请选择批次", trigger: "change" }],
tx_type: [{ required: true, message: "请选择类型", trigger: "change" }],
quantity: [{ required: true, message: "请输入数量", trigger: "blur" }],
tx_date: [{ required: true, message: "请选择日期", trigger: "change" }],
};
watch(
() => form.tx_type,
(val) => {
if (val !== "DISPENSE") {
form.subject_id = "";
}
}
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!study.currentStudy || !formRef.value) return;
await formRef.value.validate();
if (form.tx_type === "DISPENSE" && !form.subject_id) {
ElMessage.error("发放时受试者必填");
return;
}
submitting.value = true;
try {
const payload = {
...form,
site_id: form.site_id || null,
subject_id: form.subject_id || null,
};
await createImpTransaction(study.currentStudy.id, payload);
ElMessage.success("创建成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "创建失败");
} finally {
submitting.value = false;
}
};
const txTypeLabel = (t: string) =>
({
RECEIPT: "入库",
DISPENSE: "发放",
RETURN: "回收",
DESTROY: "销毁",
TRANSFER_IN: "调入",
TRANSFER_OUT: "调出",
}[t] || t);
</script>
-170
View File
@@ -1,170 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑问题' : '新增问题'" v-model="visible" width="600px" @close="onClose" class="ctms-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="受试者">
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可选" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="等级" prop="level">
<el-select v-model="form.level" placeholder="请选择">
<el-option v-for="l in levels" :key="l" :label="levelLabel(l)" :value="l" />
</el-select>
</el-form-item>
<el-form-item label="截止日期" prop="due_date">
<el-date-picker v-model="form.due_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<div class="footer-right">
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createIssue, updateIssue } from "../api/issues";
import { useStudyStore } from "../store/study";
import SubjectSelect from "./selectors/SubjectSelect.vue";
interface Props {
modelValue: boolean;
issue?: Record<string, any>;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const categories = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"];
const levels = ["LOW", "MEDIUM", "HIGH", "CRITICAL"];
const form = reactive({
title: "",
subject_id: "",
category: "ISSUE",
level: "MEDIUM",
due_date: "",
description: "",
});
const rules: FormRules = {
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
category: [{ required: true, message: "请选择类别", trigger: "change" }],
level: [{ required: true, message: "请选择等级", trigger: "change" }],
};
const isEdit = computed(() => !!props.issue);
watch(
() => props.issue,
(val) => {
if (val) {
Object.assign(form, {
title: val.title || "",
subject_id: val.subject_id || "",
category: val.category || "ISSUE",
level: val.level || "MEDIUM",
due_date: val.due_date || "",
description: val.description || "",
});
} else {
Object.assign(form, {
title: "",
subject_id: "",
category: "ISSUE",
level: "MEDIUM",
due_date: "",
description: "",
});
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload = { ...form };
if (isEdit.value && props.issue) {
await updateIssue(study.currentStudy.id, props.issue.id, payload);
} else {
await createIssue(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
const categoryLabel = (c: string) =>
({
RISK: "风险",
ISSUE: "问题",
PROTOCOL_DEVIATION: "方案偏差",
}[c] || c);
const levelLabel = (l: string) =>
({
LOW: "低",
MEDIUM: "中",
HIGH: "高",
CRITICAL: "关键",
}[l] || l);
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 6px;
}
:deep(.el-dialog__body) {
padding-top: 12px;
}
:deep(.el-dialog__footer) {
display: flex;
justify-content: flex-end;
align-items: center;
}
</style>
+52 -45
View File
@@ -35,52 +35,53 @@
<template v-if="study.currentStudy">
<div class="menu-divider">当前项目</div>
<el-menu-item index="/study/home">
<el-menu-item index="/project/overview">
<el-icon><House /></el-icon>
<span>项目概览</span>
<span>项目总览</span>
</el-menu-item>
<el-menu-item index="/study/milestones">
<el-icon><Calendar /></el-icon>
<span>伦理与启动</span>
</el-menu-item>
<el-menu-item index="/study/subjects">
<el-icon><User /></el-icon>
<span>受试者</span>
</el-menu-item>
<el-menu-item index="/study/aes">
<el-icon><WarningFilled /></el-icon>
<span>不良事件</span>
</el-menu-item>
<el-menu-item index="/study/issues">
<el-icon><Flag /></el-icon>
<span>风险与问题</span>
</el-menu-item>
<el-menu-item index="/study/data-queries">
<el-icon><ChatDotRound /></el-icon>
<span>数据问题</span>
</el-menu-item>
<el-menu-item index="/study/verifications">
<el-icon><CircleCheck /></el-icon>
<span>数据核查</span>
</el-menu-item>
<el-sub-menu index="imp">
<el-sub-menu index="finance">
<template #title>
<el-icon><Coin /></el-icon>
<span>费用管理</span>
</template>
<el-menu-item index="/finance/contracts">合同费用</el-menu-item>
<el-menu-item index="/finance/special">特殊费用</el-menu-item>
</el-sub-menu>
<el-sub-menu index="drug">
<template #title>
<el-icon><Box /></el-icon>
<span>药品管理</span>
</template>
<el-menu-item index="/study/imp/inventory">库存</el-menu-item>
<el-menu-item index="/study/imp/products">产品</el-menu-item>
<el-menu-item index="/study/imp/batches">批次</el-menu-item>
<el-menu-item index="/study/imp/transactions">交易台账</el-menu-item>
<el-menu-item index="/drug/shipments">运输/流向</el-menu-item>
</el-sub-menu>
<el-menu-item index="/study/finance">
<el-icon><Coin /></el-icon>
<span>费用管理</span>
<el-menu-item index="/startup/feasibility-ethics">
<el-icon><Calendar /></el-icon>
<span>立项与伦理</span>
</el-menu-item>
<el-menu-item index="/study/faq">
<el-icon><Notebook /></el-icon>
<span>知识库</span>
<el-menu-item index="/startup/meeting-auth">
<el-icon><CircleCheck /></el-icon>
<span>启动与授权</span>
</el-menu-item>
<el-menu-item index="/subjects">
<el-icon><User /></el-icon>
<span>受试者管理</span>
</el-menu-item>
<el-menu-item index="/monitoring">
<el-icon><ChatDotRound /></el-icon>
<span>监查</span>
</el-menu-item>
<el-menu-item index="/audit">
<el-icon><Flag /></el-icon>
<span>稽查</span>
</el-menu-item>
<el-sub-menu index="knowledge">
<template #title>
<el-icon><Notebook /></el-icon>
<span>知识库</span>
</template>
<el-menu-item index="/knowledge/medical-consult">医学咨询</el-menu-item>
<el-menu-item index="/knowledge/notes">注意事项</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</el-aside>
@@ -148,7 +149,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import StudySelector from "./StudySelector.vue";
import {
Monitor, User, Suitcase, House, Calendar, WarningFilled, Flag, ChatDotRound,
Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton
} from "@element-plus/icons-vue";
@@ -161,13 +162,19 @@ const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole =
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const activeMenu = computed(() => {
const path = route.path;
if (path.startsWith("/study/milestones/")) return "/study/milestones";
if (path.startsWith("/study/subjects/")) return "/study/subjects";
if (path.startsWith("/study/aes/")) return "/study/aes";
if (path.startsWith("/study/issues/")) return "/study/issues";
if (path.startsWith("/study/data-queries/")) return "/study/data-queries";
if (path.startsWith("/study/finance/")) return "/study/finance";
if (path.startsWith("/study/faq/")) return "/study/faq";
if (path.startsWith("/project/")) return "/project/overview";
if (path.startsWith("/finance/contracts")) return "/finance/contracts";
if (path.startsWith("/finance/special")) return "/finance/special";
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
return "/startup/meeting-auth";
}
if (path.startsWith("/subjects")) return "/subjects";
if (path.startsWith("/monitoring")) return "/monitoring";
if (path.startsWith("/audit")) return "/audit";
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
if (path.startsWith("/knowledge/notes")) return "/knowledge/notes";
if (path.startsWith("/projects/")) return "/admin/projects";
if (path.startsWith("/admin/projects/")) return "/admin/projects";
return path;
-177
View File
@@ -1,177 +0,0 @@
<template>
<el-dialog :title="isEdit ? '编辑节点' : '新增节点'" v-model="visible" width="520px" @close="onClose">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" placeholder="请选择">
<el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="中心" prop="site_id">
<el-select v-model="form.site_id" placeholder="请选择中心" filterable>
<el-option v-for="s in siteOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</el-form-item>
<el-form-item label="负责人" prop="owner_id">
<el-select v-model="form.owner_id" placeholder="请选择负责人" filterable>
<el-option v-for="m in memberOptions" :key="m.value" :label="m.label" :value="m.value" />
</el-select>
</el-form-item>
<el-form-item label="计划日期" prop="planned_date">
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="实际日期" prop="actual_date">
<el-date-picker v-model="form.actual_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择">
<el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="notes">
<el-input v-model="form.notes" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createMilestone, updateMilestone } from "../api/milestones";
import { useStudyStore } from "../store/study";
import { milestoneStatusOptions, milestoneTypeOptions } from "../dictionaries/milestone.dict";
interface Props {
modelValue: boolean;
milestone?: Record<string, any>;
sites?: any[];
members?: any[];
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "success"]);
const formRef = ref<FormInstance>();
const study = useStudyStore();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const types = milestoneTypeOptions;
const statuses = milestoneStatusOptions;
const form = reactive({
name: "",
type: "",
site_id: "",
owner_id: "",
planned_date: "",
actual_date: "",
status: "NOT_STARTED",
notes: "",
});
const rules: FormRules = {
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
type: [{ required: true, message: "请选择类型", trigger: "change" }],
site_id: [{ required: true, message: "请选择中心", trigger: "change" }],
owner_id: [{ required: true, message: "请选择负责人", trigger: "change" }],
planned_date: [{ required: true, message: "请选择计划日期", trigger: "change" }],
};
const submitting = ref(false);
const isEdit = computed(() => !!props.milestone);
const memberOptions = computed(() => {
const memberList = (props.members || []).filter((m: any) => m.is_active !== false);
const seen = new Set<string>();
return memberList
.map((m: any) => {
const user = m.user || {};
const label = user.full_name || user.display_name || user.username || user.email || m.username || m.email || "—";
return { value: m.user_id, label };
})
.filter((opt) => {
if (seen.has(opt.value)) return false;
seen.add(opt.value);
return true;
});
});
const siteOptions = computed(() =>
(props.sites || []).map((s: any) => ({
value: s.id,
label: s.name || "—",
}))
);
watch(
() => props.milestone,
(val) => {
if (val) {
form.name = val.name || "";
form.type = val.type || "";
form.site_id = val.site_id || "";
form.owner_id = val.owner_id || "";
form.planned_date = val.planned_date || "";
form.actual_date = val.actual_date || "";
form.status = val.status || "NOT_STARTED";
form.notes = val.notes || "";
} else {
form.name = "";
form.type = "";
form.site_id = "";
form.owner_id = "";
form.planned_date = "";
form.actual_date = "";
form.status = "NOT_STARTED";
form.notes = "";
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload = {
...form,
actual_date: form.actual_date || null,
};
if (isEdit.value && props.milestone) {
await updateMilestone(study.currentStudy.id, props.milestone.id, payload);
} else {
await createMilestone(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
</script>
@@ -0,0 +1,92 @@
<template>
<div class="module-page">
<div class="module-header">
<div>
<h1 class="module-title">{{ title }}</h1>
<p v-if="subtitle" class="module-subtitle">{{ subtitle }}</p>
</div>
<slot name="actions" />
</div>
<div class="module-content">
<div class="module-list">
<div class="module-list-header">
<span class="list-title">{{ listTitle }}</span>
<span class="list-note">列表区域预留</span>
</div>
<StateEmpty :title="emptyTitle" :description="emptyDescription" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import StateEmpty from "./StateEmpty.vue";
withDefaults(
defineProps<{
title: string;
subtitle?: string;
listTitle?: string;
emptyTitle?: string;
emptyDescription?: string;
}>(),
{
subtitle: "",
listTitle: "列表",
emptyTitle: "功能建设中",
emptyDescription: "该模块基础骨架已建立,后续将补充完整交互与数据能力。",
}
);
</script>
<style scoped>
.module-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.module-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.module-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--ctms-text-main);
}
.module-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.module-list {
background-color: #fff;
border: 1px solid var(--ctms-border-color);
border-radius: var(--ctms-radius);
padding: 20px;
}
.module-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.list-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.list-note {
font-size: 12px;
color: var(--ctms-text-disabled);
}
</style>
+8 -9
View File
@@ -24,20 +24,19 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import {
Pointer, ArrowRight, Timer, UserFilled, Warning,
QuestionFilled, Management, Money, Collection
Pointer, ArrowRight, Timer, UserFilled,
Management, Money, Collection
} from "@element-plus/icons-vue";
const router = useRouter();
const actions = [
{ label: "伦理与启动", path: "/study/milestones", icon: Timer },
{ label: "受试者", path: "/study/subjects", icon: UserFilled },
{ label: "不良事件", path: "/study/aes", icon: Warning },
{ label: "数据问题", path: "/study/data-queries", icon: QuestionFilled },
{ label: "药品库存", path: "/study/imp/inventory", icon: Management },
{ label: "费用管理", path: "/study/finance", icon: Money },
{ label: "项目知识库", path: "/study/faq", icon: Collection },
{ label: "立项与伦理", path: "/startup/feasibility-ethics", icon: Timer },
{ label: "启动与授权", path: "/startup/meeting-auth", icon: Timer },
{ label: "受试者管理", path: "/subjects", icon: UserFilled },
{ label: "药品流向", path: "/drug/shipments", icon: Management },
{ label: "费用管理", path: "/finance/contracts", icon: Money },
{ label: "知识库", path: "/knowledge/medical-consult", icon: Collection },
];
const go = (path: string) => router.push(path);
+1 -1
View File
@@ -64,7 +64,7 @@ const onCommand = (cmd: any) => {
}
if (cmd?.type === "switch" && cmd.study) {
study.setCurrentStudy(cmd.study as Study);
router.push("/study/home");
router.push("/project/overview");
}
};
-128
View File
@@ -1,128 +0,0 @@
<template>
<el-dialog title="新增受试者" v-model="visible" width="520px" @close="onClose" class="ctms-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
<el-form-item label="中心" prop="site_id">
<el-select v-model="form.site_id" placeholder="请选择" filterable>
<el-option v-for="s in sites" :key="s.id" :label="s.name || s.id" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="受试者编号" prop="subject_no">
<el-input v-model="form.subject_no" />
</el-form-item>
<el-form-item label="筛选日期" prop="screening_date">
<el-date-picker v-model="form.screening_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
</el-form>
<template #footer>
<div class="footer-left">
<el-checkbox v-model="keepCreating">提交后继续新建</el-checkbox>
</div>
<div class="footer-right">
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createSubject } from "../api/subjects";
import { useStudyStore } from "../store/study";
interface Props {
modelValue: boolean;
sites: any[];
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const form = reactive({
site_id: "",
subject_no: "",
screening_date: "",
});
const keepCreating = ref(false);
const rules: FormRules = {
site_id: [{ required: true, message: "请选择中心", trigger: "change" }],
subject_no: [{ required: true, message: "请输入受试者编号", trigger: "blur" }],
};
const onClose = () => {
visible.value = false;
};
const resetForm = () => {
const studyId = study.currentStudy?.id;
const recentSite = studyId ? localStorage.getItem(`recent_site_${studyId}`) : null;
form.site_id = recentSite || "";
form.subject_no = "";
form.screening_date = "";
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const { data } = await createSubject(study.currentStudy.id, { ...form });
ElMessage.success("提交成功");
if (study.currentStudy?.id) {
localStorage.setItem(`recent_site_${study.currentStudy.id}`, form.site_id || "");
}
emit("success");
if (keepCreating.value) {
resetForm();
} else {
onClose();
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
resetForm();
</script>
<style scoped>
.form-body {
display: flex;
flex-direction: column;
gap: 8px;
}
:deep(.el-dialog__body) {
padding-top: 12px;
}
:deep(.el-dialog__footer) {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left {
color: var(--ctms-text-secondary);
}
</style>
@@ -1,41 +0,0 @@
<template>
<el-table :data="verifications" v-loading="loading" style="width: 100%">
<el-table-column prop="subject_id" label="受试者">
<template #default="scope">
{{ subjectMap[scope.row.subject_id] || scope.row.subject_no || scope.row.subject_id || "-" }}
</template>
</el-table-column>
<el-table-column prop="level" label="类型" width="100" />
<el-table-column label="完成度" width="180">
<template #default="scope">
<el-progress :percentage="scope.row.percent || 0" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column prop="last_verified_at" label="最近核查" width="140">
<template #default="scope">{{ displayDate(scope.row.last_verified_at) }}</template>
</el-table-column>
<el-table-column prop="verifier_id" label="核查人" width="160">
<template #default="scope">
{{ verifierMap[scope.row.verifier_id] || "-" }}
</template>
</el-table-column>
<el-table-column v-if="canEdit" label="操作" width="120">
<template #default="scope">
<el-button type="primary" link size="small" @click="$emit('edit', scope.row)">更新进度</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { displayDate } from "../utils/display";
defineProps<{
verifications: any[];
canEdit: boolean;
loading: boolean;
subjectMap: Record<string, string>;
verifierMap: Record<string, string>;
}>();
defineEmits(["edit"]);
</script>
-72
View File
@@ -1,72 +0,0 @@
<template>
<el-card>
<div class="header">
<h4>访视计划</h4>
</div>
<el-table :data="visits" style="width: 100%" v-loading="loading">
<el-table-column prop="visit_code" label="代码" width="120" />
<el-table-column prop="visit_name" label="名称" />
<el-table-column prop="planned_date" label="计划日期" width="140" />
<el-table-column prop="actual_date" label="实际日期" width="140" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column v-if="canEdit" label="操作" width="200">
<template #default="scope">
<el-button type="primary" link size="small" @click="markDone(scope.row)">标记完成</el-button>
<el-button type="danger" link size="small" @click="markMissed(scope.row)">标记缺失</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { updateVisit } from "../api/visits";
import { useStudyStore } from "../store/study";
interface Props {
visits: any[];
subjectId: string;
canEdit: boolean;
onRefresh: () => void;
}
const props = defineProps<Props>();
const study = useStudyStore();
const loading = ref(false);
const doUpdate = async (visitId: string, payload: Record<string, any>) => {
if (!study.currentStudy) return;
loading.value = true;
try {
await updateVisit(study.currentStudy.id, props.subjectId, visitId, payload);
ElMessage.success("更新成功");
props.onRefresh();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
} finally {
loading.value = false;
}
};
const markDone = async (row: any) => {
await ElMessageBox.confirm("确认标记为完成?", "提示");
const today = new Date().toISOString().slice(0, 10);
doUpdate(row.id, { status: "DONE", actual_date: today });
};
const markMissed = async (row: any) => {
await ElMessageBox.confirm("确认标记为缺失?", "提示");
doUpdate(row.id, { status: "MISSED", actual_date: null });
};
</script>
<style scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
</style>
@@ -1,90 +0,0 @@
<template>
<el-select
:model-value="modelValue"
filterable
remote
clearable
:remote-method="onSearch"
:loading="loading"
:placeholder="placeholder || '请选择中心'"
style="width: 100%"
:disabled="disabled || !studyId"
@update:model-value="(val) => emit('update:modelValue', val)"
>
<el-option v-for="s in options" :key="s.id" :label="formatSite(s)" :value="s.id" />
<template #empty>
<div class="empty">{{ loading ? "加载中..." : "无匹配结果" }}</div>
</template>
</el-select>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, ref, watch } from "vue";
import { fetchSites } from "../../api/sites";
interface Props {
modelValue: string | null;
studyId: string | null;
placeholder?: string;
disabled?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue"]);
const options = ref<any[]>([]);
const loading = ref(false);
let timer: number | undefined;
const disabled = computed(() => props.disabled ?? false);
const formatSite = (s: any) => {
if (!s) return "";
return s.city ? `${s.name} (${s.city})` : s.name;
};
const load = async (keyword = "") => {
if (!props.studyId) {
options.value = [];
return;
}
loading.value = true;
try {
const { data } = await fetchSites(props.studyId, { keyword, limit: 20 });
options.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "中心加载失败");
} finally {
loading.value = false;
}
};
const onSearch = (keyword: string) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => load(keyword), 300);
};
watch(
() => props.studyId,
() => {
load("");
},
{ immediate: true }
);
watch(
() => props.modelValue,
(val) => {
if (val && options.value.length === 0) {
load("");
}
}
);
</script>
<style scoped>
.empty {
padding: 8px;
color: #999;
}
</style>
@@ -1,98 +0,0 @@
<template>
<el-select
:model-value="modelValue"
filterable
remote
clearable
:remote-method="onSearch"
:loading="loading"
:placeholder="placeholder || '请选择受试者'"
style="width: 100%"
:disabled="disabled || !studyId"
@update:model-value="(val) => emit('update:modelValue', val)"
>
<el-option
v-for="s in options"
:key="s.id"
:label="formatSubject(s)"
:value="s.id"
/>
<template #empty>
<div class="empty">{{ loading ? "加载中..." : "无匹配结果" }}</div>
</template>
</el-select>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, ref, watch } from "vue";
import { fetchSubjects } from "../../api/subjects";
interface Props {
modelValue: string | null;
studyId: string | null;
placeholder?: string;
status?: string;
disabled?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue"]);
const options = ref<any[]>([]);
const loading = ref(false);
let timer: number | undefined;
const disabled = computed(() => props.disabled ?? false);
const formatSubject = (s: any) => {
if (!s) return "";
return s.status ? `${s.subject_no || s.id} (${s.status})` : s.subject_no || s.id;
};
const load = async (keyword = "") => {
if (!props.studyId) {
options.value = [];
return;
}
loading.value = true;
try {
const params: Record<string, any> = { keyword, limit: 20 };
if (props.status) params.status = props.status;
const { data } = await fetchSubjects(props.studyId, params);
options.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "受试者加载失败");
} finally {
loading.value = false;
}
};
const onSearch = (keyword: string) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => load(keyword), 300);
};
watch(
() => props.studyId,
() => {
load("");
},
{ immediate: true }
);
watch(
() => props.modelValue,
(val) => {
if (val && options.value.length === 0) {
load("");
}
}
);
</script>
<style scoped>
.empty {
padding: 8px;
color: #999;
}
</style>
@@ -1,81 +0,0 @@
<template>
<el-select
:model-value="modelValue"
:multiple="multiple"
filterable
remote
clearable
:remote-method="onSearch"
:loading="loading"
:placeholder="placeholder || '请选择用户'"
:disabled="disabled"
style="width: 100%"
@update:model-value="(val) => emit('update:modelValue', val)"
>
<el-option
v-for="u in options"
:key="u.id"
:label="`${u.username}${u.role ? ' (' + u.role + ')' : ''}`"
:value="u.id"
/>
<template #empty>
<div class="empty">{{ loading ? "加载中..." : "无匹配结果" }}</div>
</template>
</el-select>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, ref, watch } from "vue";
import { fetchUsers } from "../../api/users";
interface Props {
modelValue: string | string[] | null;
placeholder?: string;
multiple?: boolean;
disabled?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue"]);
const options = ref<any[]>([]);
const loading = ref(false);
let timer: number | undefined;
const disabled = computed(() => props.disabled ?? false);
const load = async (keyword = "") => {
loading.value = true;
try {
const { data } = await fetchUsers({ keyword, limit: 20 });
options.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "用户加载失败");
} finally {
loading.value = false;
}
};
const onSearch = (keyword: string) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => load(keyword), 300);
};
watch(
() => props.modelValue,
(val) => {
// 初次渲染时拉一版数据,确保已选项有 label
if (val && options.value.length === 0) {
load("");
}
},
{ immediate: true }
);
</script>
<style scoped>
.empty {
padding: 8px;
color: #999;
}
</style>