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