Files
ctms/frontend/src/components/CommentList.vue
T
2025-12-30 14:07:57 +08:00

205 lines
5.6 KiB
Vue

<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>