588 lines
17 KiB
Vue
588 lines
17 KiB
Vue
<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>
|