Files
ctms/frontend/src/views/FaqDetail.vue
T

588 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page unified-shell">
<el-card class="unified-shell" v-loading="loading">
<div class="content">
<div class="question-header">
<div class="question-label">{{ TEXT.modules.knowledgeMedicalConsult.questionDesc }}</div>
<div class="question-actions">
<el-button v-if="canEditQuestion" type="primary" size="small" class="edit-btn" @click="openEdit">
{{ TEXT.modules.knowledgeMedicalConsult.editQuestion }}
</el-button>
<el-button v-if="canConfirmResolved" type="success" size="small" @click="confirmResolved">
{{ TEXT.modules.knowledgeMedicalConsult.confirmResolved }}
</el-button>
</div>
</div>
<div class="question-meta">
<span class="meta-pill meta-user"
>{{ TEXT.modules.knowledgeMedicalConsult.asker }}{{ displayUser(item?.created_by, { members: memberMap, users: userMap }) }}</span
>
<span class="meta-pill meta-time">{{ TEXT.modules.knowledgeMedicalConsult.askedAt }}{{ displayDateTime(item?.created_at) }}</span>
<span class="meta-pill meta-category">{{ TEXT.common.labels.category }}{{ categoryName }}</span>
<span class="meta-pill meta-status">{{ TEXT.common.fields.status }}{{ statusLabel }}</span>
</div>
<pre class="question-text">{{ item?.question }}</pre>
</div>
<div v-if="bestReply" class="best-answer">
<div class="best-title">
<span>{{ TEXT.modules.knowledgeMedicalConsult.bestAnswer }}</span>
<el-tag type="success" size="small">{{ TEXT.modules.knowledgeMedicalConsult.adopted }}</el-tag>
</div>
<div class="best-meta">
{{ displayUser(bestReply.created_by, { members: memberMap, users: userMap }) }}
· {{ displayDateTime(bestReply.created_at) }}
</div>
<div class="best-content">{{ bestReply.is_deleted ? TEXT.modules.knowledgeMedicalConsult.replyDeleted : bestReply.content }}</div>
</div>
</el-card>
<el-card class="mt-12 unified-shell" v-loading="repliesLoading">
<template #header>
<div class="reply-header">
<span>{{ TEXT.modules.knowledgeMedicalConsult.replies }}</span>
<span class="reply-count">{{ TEXT.modules.knowledgeMedicalConsult.replyCount.replace("{count}", String(replies.length)) }}</span>
</div>
</template>
<div v-if="canReply" class="reply-form">
<ThreadComposer
v-model="replyContent"
v-model:file-list="replyFiles"
:submitting="submitting"
:quote-item="quoteReply"
:member-map="memberMap"
:user-map="userMap"
:allow-attachments="canUploadReplyAttachment"
@submit="submitReply"
@clear-quote="clearQuote"
/>
</div>
<div v-else class="reply-disabled">{{ TEXT.modules.knowledgeMedicalConsult.noReplyPermission }}</div>
<el-divider v-if="replies.length" />
<ThreadList
v-if="replies.length"
:items="replies"
:attachments-map="replyAttachmentsMap"
:member-map="memberMap"
:user-map="userMap"
:can-quote="canReply"
:can-delete="canDeleteReply"
:highlight-ids="bestReplyId ? [bestReplyId] : []"
@quote="setQuote"
@delete="removeReply"
>
<template #actions="{ item: r }">
<el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">{{ TEXT.modules.knowledgeMedicalConsult.bestAnswer }}</el-tag>
<el-button v-if="canSelectBest && !r.is_deleted" type="text" size="small" @click="toggleBest(r)">
{{ item?.best_reply_id === r.id ? TEXT.modules.knowledgeMedicalConsult.cancelBest : TEXT.modules.knowledgeMedicalConsult.setBest }}
</el-button>
</template>
</ThreadList>
<div v-else class="reply-empty">{{ TEXT.modules.knowledgeMedicalConsult.emptyReplies }}</div>
</el-card>
<FaqItemForm
v-model="showForm"
:item="editing"
:categories="categories"
@success="onEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import type { UploadUserFile } from "element-plus";
import {
createFaqReply,
deleteFaqReply,
fetchFaqItem,
fetchFaqReplies,
fetchFaqCategories,
setFaqBestReply,
setFaqStatus,
} from "../api/faqs";
import { listMembers } from "../api/members";
import { fetchAttachments, uploadAttachment } from "../api/attachments";
import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { usePermission } from "../utils/permission";
import { getAttachmentPermissionKey } from "../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import FaqItemForm from "../components/FaqItemForm.vue";
import ThreadComposer from "../components/ThreadComposer.vue";
import ThreadList from "../components/ThreadList.vue";
import { TEXT } from "../locales";
const route = useRoute();
const auth = useAuthStore();
const study = useStudyStore();
const item = ref<any>(null);
const loading = ref(false);
const categories = ref<any[]>([]);
const replies = ref<any[]>([]);
const repliesLoading = ref(false);
const replyContent = ref("");
const submitting = ref(false);
const quoteReply = ref<any | null>(null);
const members = ref<any[]>([]);
const showForm = ref(false);
const editing = ref<any | null>(null);
const replyFiles = ref<UploadUserFile[]>([]);
const replyAttachmentsMap = ref<Record<string, any[]>>({});
const { can } = usePermission();
const canReadCategories = computed(() => can("faq.category.read"));
const canReadMembers = computed(() => can("project.members.list"));
const canReply = computed(() => {
if (!item.value) return false;
return can("faq.reply");
});
const canUploadReplyAttachment = computed(() => {
if (!item.value?.study_id) return false;
if (auth.user?.is_admin) return true;
const role = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
const operationKey = getAttachmentPermissionKey("faq_replies", "create");
return !!role && !!operationKey && isApiPermissionAllowed(study.currentPermissions?.[role]?.[operationKey]);
});
const canDeleteReply = (reply: any) => {
if (reply.is_deleted) return false;
if (!item.value?.study_id) return false;
return can("faq.reply.delete");
};
const canSelectBest = computed(() => {
if (!item.value) return false;
return can("faq.update");
});
const canEditQuestion = computed(() => {
if (!item.value) return false;
return can("faq.update");
});
const canConfirmResolved = computed(() => {
if (!item.value) return false;
if (item.value.status === "RESOLVED") return false;
return can("faq.update");
});
const statusLabel = computed(() => {
return TEXT.enums.faqStatus[item.value?.status as keyof typeof TEXT.enums.faqStatus] || TEXT.enums.faqStatus.PENDING;
});
const bestReply = computed(() => {
if (!item.value?.best_reply_id) return null;
return replies.value.find((r) => r.id === item.value.best_reply_id) || null;
});
const bestReplyId = computed(() => item.value?.best_reply_id || null);
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) {
const name = getUserDisplayName(auth.user) || auth.user.id;
map[auth.user.id] = name;
}
const push = (id?: string | null, name?: string | null) => {
if (!id || !name) return;
map[id] = name;
};
push(item.value?.created_by, item.value?.created_by_name || item.value?.created_by_display_name || item.value?.created_by_email);
replies.value.forEach((r) => {
push(r?.created_by, r?.created_by_name || r?.created_by_display_name || r?.created_by_email);
push(r?.quote?.created_by, r?.quote?.created_by_name || r?.quote?.created_by_display_name || r?.quote?.created_by_email);
});
return map;
});
const categoryName = computed(() => {
const cat = categories.value.find((c) => c.id === item.value?.category_id);
return cat ? cat.name : item.value?.category_id;
});
const loadMembers = async (studyId?: string | null) => {
if (!canReadMembers.value) {
members.value = [];
return;
}
if (!studyId) {
members.value = [];
return;
}
try {
const { data } = await listMembers(studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const loadReplies = async () => {
const id = route.params.itemId as string;
repliesLoading.value = true;
try {
const { data } = await fetchFaqReplies(id, item.value?.study_id || study.currentStudy?.id);
replies.value = Array.isArray(data) ? data : data.items || [];
await loadReplyAttachments();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.knowledgeMedicalConsult.replyLoadFailed);
} finally {
repliesLoading.value = false;
}
};
const loadData = async () => {
const id = route.params.itemId as string;
loading.value = true;
try {
const { data: faqData } = await fetchFaqItem(id, study.currentStudy?.id);
item.value = faqData;
const questionTitle = String(faqData.question || "").trim();
study.setViewContext({
pageTitle: questionTitle ? questionTitle.slice(0, 24) : TEXT.modules.knowledgeMedicalConsult.detailTitle,
});
const categoryParams: Record<string, any> = {};
if (faqData.study_id) {
categoryParams.study_id = faqData.study_id;
}
categories.value = [];
if (canReadCategories.value) {
const { data: catData } = await fetchFaqCategories(categoryParams);
categories.value = catData.items || catData || [];
}
await Promise.all([loadReplies(), loadMembers(faqData.study_id)]);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const setQuote = (reply: any) => {
quoteReply.value = reply;
};
const clearQuote = () => {
quoteReply.value = null;
};
const submitReply = async () => {
if (!replyContent.value.trim()) {
ElMessage.warning(TEXT.modules.knowledgeMedicalConsult.replyRequired);
return;
}
if (!item.value) return;
submitting.value = true;
try {
const { data } = await createFaqReply(item.value.id, item.value.study_id, {
content: replyContent.value,
quote_reply_id: quoteReply.value?.id || null,
});
const created = data as any;
if (canUploadReplyAttachment.value && replyFiles.value.length && created?.id) {
try {
for (const file of replyFiles.value) {
if (!file.raw) continue;
await uploadAttachment(item.value.study_id, "faq_replies", created.id, file.raw as File);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.uploadFailed);
}
}
ElMessage.success(TEXT.modules.knowledgeMedicalConsult.replySubmitted);
replyContent.value = "";
quoteReply.value = null;
replyFiles.value = [];
loadReplies();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.knowledgeMedicalConsult.replyFailed);
} finally {
submitting.value = false;
}
};
const removeReply = async (reply: any) => {
if (!item.value) return;
if (!canDeleteReply(reply)) {
ElMessage.warning("权限不足");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteFaqReply(item.value.id, reply.id, item.value.study_id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
loadReplies();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const toggleBest = async (reply: any) => {
if (!item.value) return;
if (!canSelectBest.value) {
ElMessage.warning("权限不足");
return;
}
try {
const target = item.value.best_reply_id === reply.id ? null : reply.id;
if (target) {
const ok = await ElMessageBox.confirm(TEXT.modules.knowledgeMedicalConsult.confirmBestReply, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
}
const { data } = await setFaqBestReply(item.value.id, item.value.study_id, { best_reply_id: target });
item.value = data;
ElMessage.success(target ? TEXT.modules.knowledgeMedicalConsult.bestReplySet : TEXT.modules.knowledgeMedicalConsult.bestReplyCleared);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
}
};
const confirmResolved = async () => {
if (!item.value) return;
if (!canConfirmResolved.value) {
ElMessage.warning("权限不足");
return;
}
try {
const { data } = await setFaqStatus(item.value.id, item.value.study_id, { status: "RESOLVED" });
item.value = data;
ElMessage.success(TEXT.modules.knowledgeMedicalConsult.resolvedSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
}
};
const openEdit = () => {
if (!canEditQuestion.value) {
ElMessage.warning("权限不足");
return;
}
editing.value = item.value;
showForm.value = true;
};
const onEditSuccess = () => {
showForm.value = false;
loadData();
};
const loadReplyAttachments = async () => {
if (!item.value?.study_id || replies.value.length === 0) {
replyAttachmentsMap.value = {};
return;
}
const entries = await Promise.all(
replies.value.map(async (r) => {
try {
const { data } = await fetchAttachments(item.value.study_id, "faq_replies", r.id);
const items = (data as any).items || data || [];
return [r.id, items] as const;
} catch {
return [r.id, []] as const;
}
})
);
const next: Record<string, any[]> = {};
entries.forEach(([id, items]) => {
next[id] = items;
});
replyAttachmentsMap.value = next;
};
onMounted(() => {
loadData();
});
</script>
<style scoped>
.page {
padding: 16px;
}
:deep(.el-card.unified-shell) {
border-radius: 8px !important;
}
.page :deep(.unified-shell) {
border-radius: 8px;
}
.content {
white-space: pre-wrap;
line-height: 1.6;
}
.question-label {
margin-bottom: 8px;
font-weight: 600;
color: #303133;
}
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.question-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.edit-btn {
font-weight: 600;
}
.question-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: #606266;
font-size: 12px;
margin-bottom: 8px;
}
.meta-pill {
padding: 4px 10px;
border-radius: 999px;
font-weight: 500;
border: 1px solid transparent;
}
.meta-user {
background: #eef2ff;
border-color: #c7d2fe;
color: #3730a3;
}
.meta-time {
background: #fef9c3;
border-color: #fde68a;
color: #92400e;
}
.meta-category {
background: #ecfccb;
border-color: #bef264;
color: #3f6212;
}
.meta-status {
background: #ecfeff;
border-color: #a5f3fc;
color: #155e75;
}
.confirm-row {
margin-bottom: 8px;
}
.ml-8 {
margin-left: 8px;
}
.mt-12 {
margin-top: 12px;
}
.reply-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.reply-count {
color: #909399;
font-size: 12px;
}
.reply-form {
margin-bottom: 12px;
}
.reply-actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.reply-disabled {
color: #909399;
font-size: 12px;
}
.reply-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.reply-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.reply-actions-inline {
display: flex;
gap: 8px;
align-items: center;
}
.reply-content {
white-space: pre-wrap;
}
.danger {
color: #f56c6c;
}
.best-answer {
margin-top: 16px;
padding: 12px 14px;
border: 1px solid #e1f3d8;
background: #f0f9eb;
border-radius: 8px;
}
.best-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #2f6f2f;
margin-bottom: 6px;
}
.best-meta {
color: #606266;
font-size: 12px;
margin-bottom: 8px;
}
.best-content {
white-space: pre-wrap;
}
.question-text {
margin: 6px 0 0;
padding: 12px 14px;
border-radius: 8px;
background: #f5f7ff;
border: 1px solid #e0e7ff;
color: #1f2a44;
font-size: 16px;
font-weight: 600;
}
.quote-block,
.quote-box {
background: #f5f7fa;
border-left: 3px solid #dcdfe6;
padding: 8px 10px;
border-radius: 4px;
}
.quote-meta {
display: flex;
justify-content: space-between;
color: #909399;
font-size: 12px;
margin-bottom: 6px;
}
.quote-content {
white-space: pre-wrap;
}
.reply-empty {
color: #909399;
font-size: 12px;
}
</style>