细节优化——1

This commit is contained in:
Cheng Zhou
2025-12-30 14:07:57 +08:00
parent 71db309e12
commit 0c1fc49f17
40 changed files with 1610 additions and 389 deletions
+32 -9
View File
@@ -8,12 +8,17 @@
<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" />
<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="严重性" prop="seriousness">
<el-form-item label="SAE" prop="seriousness">
<el-select v-model="form.seriousness" placeholder="请选择">
<el-option label="SERIOUS(重)" value="SERIOUS" />
<el-option label="NON_SERIOUS(非重)" value="NON_SERIOUS" />
<el-option label="" value="SERIOUS" />
<el-option label="" value="NON_SERIOUS" />
</el-select>
</el-form-item>
<el-form-item label="严重程度" prop="severity">
@@ -24,6 +29,11 @@
<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">
@@ -59,13 +69,14 @@ const submitting = ref(false);
const study = useStudyStore();
const severities = ["G1", "G2", "G3", "G4", "G5"];
const outcomes = ["痊愈", "好转", "持续", "恶化", "死亡", "其他"];
const severityLabel = (v: string) =>
({
G1: "G1(轻)",
G2: "G2(中)",
G3: "G3(重)",
G4: "G4(危重)",
G5: "G5(致死)",
G1: "I级",
G2: "II级",
G3: "III级",
G4: "IV级",
G5: "V级",
}[v] || v);
const form = reactive({
@@ -75,6 +86,7 @@ const form = reactive({
seriousness: "NON_SERIOUS",
severity: "G1",
description: "",
outcome: "",
});
const rules: FormRules = {
@@ -97,6 +109,7 @@ watch(
seriousness: val.seriousness || "NON_SERIOUS",
severity: val.severity || "G1",
description: val.description || "",
outcome: val.outcome || "",
});
} else {
Object.assign(form, {
@@ -106,6 +119,7 @@ watch(
seriousness: "NON_SERIOUS",
severity: "G1",
description: "",
outcome: "",
});
}
},
@@ -116,10 +130,16 @@ 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;
@@ -132,6 +152,9 @@ const onSubmit = async () => {
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 {
+110 -31
View File
@@ -6,31 +6,45 @@
<el-button v-if="canComment" type="primary" size="small" @click="showInput = !showInput">新增</el-button>
</div>
</template>
<div v-if="showInput" class="input-area">
<el-input v-model="newComment" type="textarea" rows="3" placeholder="输入评论" />
<div class="actions">
<el-button size="small" @click="showInput = false">取消</el-button>
<el-button size="small" type="primary" :loading="loading" @click="submit">提交</el-button>
</div>
</div>
<el-timeline>
<el-timeline-item v-for="c in comments" :key="c.id" :timestamp="displayDateTime(c.created_at)">
<div class="comment-item">
<strong>{{ displayUser(c.created_by, { users: userMap, members: memberMap }) }}</strong>
<p>{{ c.content }}</p>
</div>
</el-timeline-item>
</el-timeline>
<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 } from "element-plus";
import { fetchComments, createComment } from "../api/comments";
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 { displayDateTime, displayUser, getMemberDisplayName } from "../utils/display";
import { getMemberDisplayName } from "../utils/display";
import ThreadComposer from "./ThreadComposer.vue";
import ThreadList from "./ThreadList.vue";
interface Props {
studyId: string;
@@ -45,7 +59,12 @@ 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;
@@ -53,6 +72,7 @@ const load = async () => {
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 {
@@ -77,6 +97,13 @@ const memberMap = computed(() =>
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()) {
@@ -86,9 +113,26 @@ const submit = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
await createComment(study.currentStudy.id, props.entityType, props.entityId, { content: newComment.value });
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) {
@@ -98,6 +142,53 @@ const submit = async () => {
}
};
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();
@@ -110,16 +201,4 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
}
.input-area {
margin-bottom: 12px;
}
.actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.comment-item p {
margin: 4px 0 0;
}
</style>
+17 -3
View File
@@ -7,7 +7,7 @@
</div>
<el-menu
router
:default-active="$route.path"
:default-active="activeMenu"
:collapse="isCollapsed"
:collapse-transition="true"
class="aside-menu"
@@ -75,7 +75,7 @@
</el-sub-menu>
<el-menu-item index="/study/finance">
<el-icon><Coin /></el-icon>
<span>费用</span>
<span>费用</span>
</el-menu-item>
<el-menu-item index="/study/faq">
<el-icon><Notebook /></el-icon>
@@ -143,7 +143,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import StudySelector from "./StudySelector.vue";
@@ -155,9 +155,23 @@ import {
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const route = useRoute();
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isPm = computed(() => auth.user?.role === "PM" || study.currentStudyRole === "PM");
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("/projects/")) return "/admin/projects";
if (path.startsWith("/admin/projects/")) return "/admin/projects";
return path;
});
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value;
+1 -1
View File
@@ -36,7 +36,7 @@ const actions = [
{ 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/finance", icon: Money },
{ label: "项目知识库", path: "/study/faq", icon: Collection },
];
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="thread-composer">
<div v-if="quoteItem" class="quote-box">
<div class="quote-meta">
引用 {{ displayUser(quoteItem.created_by, { users: userMap, members: memberMap }) }}
· {{ displayDateTime(quoteItem.created_at) }}
<el-button type="text" size="small" @click="$emit('clear-quote')">取消引用</el-button>
</div>
<div class="quote-content">{{ quoteContent(quoteItem) }}</div>
</div>
<el-input v-model="contentProxy" type="textarea" rows="3" placeholder="输入内容" />
<div v-if="allowAttachments" class="upload-row">
<div class="upload-label">附件</div>
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
<el-button size="small" class="upload-button">上传附件</el-button>
</el-upload>
</div>
<div class="actions">
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">取消</el-button>
<el-button size="small" type="primary" :loading="submitting" @click="$emit('submit')">提交</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display";
const props = defineProps<{
modelValue: string;
fileList: UploadUserFile[];
submitting?: boolean;
showCancel?: boolean;
allowAttachments?: boolean;
quoteItem?: any | null;
memberMap?: Record<string, string>;
userMap?: Record<string, string>;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
(e: "update:fileList", value: UploadUserFile[]): void;
(e: "submit"): void;
(e: "cancel"): void;
(e: "clear-quote"): void;
}>();
const contentProxy = computed({
get: () => props.modelValue,
set: (val: string) => emit("update:modelValue", val),
});
const fileListProxy = computed({
get: () => props.fileList,
set: (val: UploadUserFile[]) => emit("update:fileList", val),
});
const quoteContent = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—");
</script>
<style scoped>
.thread-composer {
margin-bottom: 8px;
}
.upload-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.upload-label {
font-size: 12px;
color: #606266;
padding: 4px 8px;
border-radius: 999px;
background: #f2f4f7;
}
.thread-upload :deep(.el-upload-list) {
margin-top: 6px;
}
.upload-button {
border: 1px dashed #3b82f6;
color: #1d4ed8;
background: #eff6ff;
font-weight: 600;
}
.upload-button:hover {
border-color: #2563eb;
color: #1e40af;
background: #e0ecff;
}
.actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.quote-box {
background: #f5f7fa;
border-left: 3px solid #dcdfe6;
padding: 8px 10px;
border-radius: 4px;
margin-bottom: 8px;
}
.quote-meta {
display: flex;
justify-content: space-between;
color: #909399;
font-size: 12px;
margin-bottom: 6px;
}
.quote-content {
white-space: pre-wrap;
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<template>
<el-timeline>
<el-timeline-item v-for="item in items" :key="item.id" :timestamp="displayDateTime(item.created_at)">
<div class="thread-item" :class="{ 'is-highlighted': isHighlighted(item) }">
<div class="thread-meta">
<strong>{{ displayUser(item.created_by, { users: userMap, members: memberMap }) }}</strong>
<div class="thread-actions">
<el-button v-if="canQuote" type="text" size="small" @click="$emit('quote', item)">引用</el-button>
<el-button v-if="canDelete?.(item)" type="text" size="small" class="danger" @click="$emit('delete', item)">
删除
</el-button>
<slot name="actions" :item="item" />
</div>
</div>
<div v-if="item.quote" class="quote-block">
<div class="quote-meta">
{{ displayUser(item.quote.created_by, { users: userMap, members: memberMap }) }}
· {{ displayDateTime(item.quote.created_at) }}
</div>
<div class="quote-content">{{ quoteContent(item.quote) }}</div>
</div>
<div v-if="attachmentsMap[item.id]?.length" class="thread-attachments">
<div v-for="file in attachmentsMap[item.id]" :key="file.id" class="attachment-item">
<el-image
v-if="isImage(file)"
:src="attachmentUrl(file.id)"
:preview-src-list="[attachmentUrl(file.id)]"
fit="cover"
preview-teleported
class="attachment-image"
/>
<div v-if="isImage(file)" class="attachment-name" :title="file.filename">
{{ file.filename }}
</div>
<el-link v-else :underline="false" class="file-link" @click="download(file.id)">
<span class="file-badge">文件</span>
<span class="file-name">{{ file.filename }}</span>
</el-link>
</div>
</div>
<p>{{ contentText(item) }}</p>
</div>
</el-timeline-item>
</el-timeline>
</template>
<script setup lang="ts">
import { displayDateTime, displayUser } from "../utils/display";
const props = defineProps<{
items: any[];
attachmentsMap: Record<string, any[]>;
memberMap?: Record<string, string>;
userMap?: Record<string, string>;
canQuote?: boolean;
canDelete?: (item: any) => boolean;
highlightIds?: Array<string | number>;
}>();
defineEmits<{
(e: "quote", item: any): void;
(e: "delete", item: any): void;
}>();
const quoteContent = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—");
const contentText = (item: any) => (item?.is_deleted ? "内容已删除" : item?.content || "—");
const attachmentUrl = (id: string) => {
const token = localStorage.getItem("ctms_token");
return token ? `/api/v1/attachments/${id}/download?token=${token}` : `/api/v1/attachments/${id}/download`;
};
const download = (id: string) => {
window.open(attachmentUrl(id), "_blank");
};
const isImage = (file: any) => {
const type = String(file?.content_type || "").toLowerCase();
if (type.startsWith("image/")) return true;
const name = String(file?.filename || "").toLowerCase();
return [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"].some((ext) => name.endsWith(ext));
};
const isHighlighted = (item: any) => {
if (!props.highlightIds?.length) return false;
return props.highlightIds.includes(item?.id);
};
</script>
<style scoped>
.thread-item.is-highlighted {
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 10px;
padding: 10px 12px;
}
.thread-item {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #edf1f7;
background: #ffffff;
margin: 6px 0;
}
.thread-item.is-highlighted {
border-color: #fed7aa;
}
.thread-item p {
margin: 4px 0 0;
}
.thread-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.thread-actions {
display: flex;
gap: 8px;
align-items: center;
}
.quote-block {
background: #f5f7fa;
border-left: 3px solid #dcdfe6;
padding: 8px 10px;
border-radius: 4px;
margin-top: 6px;
}
.quote-meta {
display: flex;
justify-content: space-between;
color: #909399;
font-size: 12px;
margin-bottom: 6px;
}
.quote-content {
white-space: pre-wrap;
}
.thread-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.attachment-item {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
max-width: 140px;
}
.attachment-image {
width: 128px;
height: 96px;
border-radius: 10px;
border: 1px solid #ebeef5;
background: #fafafa;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
}
.attachment-name {
font-size: 12px;
color: #606266;
max-width: 128px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #1f2937;
font-weight: 600;
}
.file-link:hover {
border-color: #cbd5f5;
background: #eef2ff;
}
.file-badge {
font-size: 11px;
color: #334155;
background: #e2e8f0;
padding: 2px 6px;
border-radius: 999px;
}
.file-name {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.danger {
color: #f56c6c;
}
</style>