Files
ctms/frontend/src/views/FaqDetail.vue
T
2025-12-26 14:07:28 +08:00

506 lines
14 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">
<el-card v-loading="loading">
<div class="content">
<div class="question-header">
<div class="question-label">问题描述</div>
<div class="question-actions">
<el-button v-if="canEditQuestion" type="primary" size="small" class="edit-btn" @click="openEdit">
编辑问题
</el-button>
<el-button v-if="canConfirmResolved" type="success" size="small" @click="confirmResolved">
确认解决
</el-button>
</div>
</div>
<div class="question-meta">
<span class="meta-pill meta-user"
>提问人{{ displayUser(item?.created_by, { members: memberMap, users: userMap }) }}</span
>
<span class="meta-pill meta-time">提问时间{{ displayDateTime(item?.created_at) }}</span>
<span class="meta-pill meta-category">分类{{ categoryName }}</span>
<span class="meta-pill meta-status">状态{{ statusLabel }}</span>
</div>
<pre class="question-text">{{ item?.question }}</pre>
</div>
<div v-if="bestReply" class="best-answer">
<div class="best-title">
<span>最佳答案</span>
<el-tag type="success" size="small">已采纳</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 ? "回复已删除" : bestReply.content }}</div>
</div>
</el-card>
<el-card class="mt-12" v-loading="repliesLoading">
<template #header>
<div class="reply-header">
<span>回复</span>
<span class="reply-count"> {{ replies.length }} </span>
</div>
</template>
<div v-if="canReply" class="reply-form">
<div v-if="quoteReply" class="quote-box">
<div class="quote-meta">
引用 {{ displayUser(quoteReply.created_by, { members: memberMap, users: userMap }) }}
· {{ displayDateTime(quoteReply.created_at) }}
<el-button type="text" size="small" @click="clearQuote">取消引用</el-button>
</div>
<div class="quote-content">{{ quoteReply.content }}</div>
</div>
<el-input v-model="replyContent" type="textarea" :rows="4" placeholder="请输入回复内容" />
<div class="reply-actions">
<el-button type="primary" :loading="submitting" @click="submitReply">提交回复</el-button>
</div>
</div>
<div v-else class="reply-disabled">当前角色无权限回复</div>
<el-divider v-if="replies.length" />
<el-timeline v-if="replies.length">
<el-timeline-item v-for="r in replies" :key="r.id" :timestamp="displayDateTime(r.created_at)">
<div class="reply-item">
<div class="reply-meta">
<strong>{{ displayUser(r.created_by, { members: memberMap, users: userMap }) }}</strong>
<div class="reply-actions-inline">
<el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">最佳答案</el-tag>
<el-button v-if="canReply" type="text" size="small" @click="setQuote(r)">引用</el-button>
<el-button
v-if="canSelectBest && !r.is_deleted"
type="text"
size="small"
@click="toggleBest(r)"
>
{{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }}
</el-button>
<el-button v-if="canDeleteReply(r)" type="text" size="small" class="danger" @click="removeReply(r)">
删除
</el-button>
</div>
</div>
<div v-if="r.quote" class="quote-block">
<div class="quote-meta">
{{ displayUser(r.quote.created_by, { members: memberMap, users: userMap }) }}
· {{ displayDateTime(r.quote.created_at) }}
</div>
<div class="quote-content">{{ r.quote.content }}</div>
</div>
<div class="reply-content">{{ r.is_deleted ? "回复已删除" : r.content }}</div>
</div>
</el-timeline-item>
</el-timeline>
<div v-else class="reply-empty">暂无回复</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 {
createFaqReply,
deleteFaqReply,
fetchFaqItem,
fetchFaqReplies,
fetchFaqCategories,
setFaqBestReply,
setFaqStatus,
} from "../api/faqs";
import { listMembers } from "../api/members";
import { displayDateTime, displayUser } from "../utils/display";
import { useAuthStore } from "../store/auth";
import { usePermission } from "../utils/permission";
import FaqItemForm from "../components/FaqItemForm.vue";
const route = useRoute();
const auth = useAuthStore();
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 { can } = usePermission();
const canReply = computed(() => {
if (!item.value) return false;
if (!item.value.study_id) return auth.user?.role === "ADMIN";
return can("faq.reply");
});
const canDeleteReply = (reply: any) => {
if (reply.is_deleted) return false;
if (auth.user?.role === "ADMIN") return true;
if (reply.created_by === auth.user?.id) return true;
return item.value?.study_id ? can("faq.edit") : false;
};
const canSelectBest = computed(() => {
if (!item.value) return false;
if (!item.value.study_id) return auth.user?.role === "ADMIN";
return can("faq.reply");
});
const canEditQuestion = computed(() => {
if (!item.value) return false;
if (auth.user?.role === "ADMIN") return true;
if (item.value.created_by === auth.user?.id) return true;
return item.value?.study_id ? can("faq.edit") : false;
});
const canConfirmResolved = computed(() => {
if (!item.value) return false;
if (item.value.status === "RESOLVED") return false;
if (auth.user?.role === "ADMIN") return true;
if (item.value.created_by === auth.user?.id) return true;
return item.value?.study_id ? can("faq.edit") : false;
});
const statusLabel = computed(() => {
if (item.value?.status === "RESOLVED") return "已解决";
if (item.value?.status === "PROCESSING") return "处理中";
return "待回答";
});
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 memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {})
);
const userMap = computed(() => {
if (!auth.user?.id) return {};
const name = auth.user.display_name || auth.user.username || auth.user.id;
return { [auth.user.id]: name };
});
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 (!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);
replies.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "回复加载失败");
} finally {
repliesLoading.value = false;
}
};
const loadData = async () => {
const id = route.params.itemId as string;
loading.value = true;
try {
const { data: faqData } = await fetchFaqItem(id);
item.value = faqData;
const categoryParams: Record<string, any> = {};
if (faqData.study_id) {
categoryParams.study_id = faqData.study_id;
categoryParams.include_global = false;
}
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 || "加载失败");
} 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("请输入回复内容");
return;
}
if (!item.value) return;
submitting.value = true;
try {
await createFaqReply(item.value.id, {
content: replyContent.value,
quote_reply_id: quoteReply.value?.id || null,
});
ElMessage.success("回复已提交");
replyContent.value = "";
quoteReply.value = null;
loadReplies();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "回复失败");
} finally {
submitting.value = false;
}
};
const removeReply = async (reply: any) => {
if (!item.value) return;
const ok = await ElMessageBox.confirm("确认删除该回复?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteFaqReply(item.value.id, reply.id);
ElMessage.success("回复已删除");
loadReplies();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
const toggleBest = async (reply: any) => {
if (!item.value) return;
try {
const target = item.value.best_reply_id === reply.id ? null : reply.id;
if (target) {
const ok = await ElMessageBox.confirm("设为最佳答案并确认已解决?", "提示").catch(() => null);
if (!ok) return;
}
const { data } = await setFaqBestReply(item.value.id, { best_reply_id: target });
item.value = data;
ElMessage.success(target ? "已设为最佳答案" : "已取消最佳答案");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
};
const confirmResolved = async () => {
if (!item.value) return;
try {
const { data } = await setFaqStatus(item.value.id, { status: "RESOLVED" });
item.value = data;
ElMessage.success("已设置为已解决");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
};
const openEdit = () => {
editing.value = item.value;
showForm.value = true;
};
const onEditSuccess = () => {
showForm.value = false;
loadData();
};
onMounted(() => {
loadData();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.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: 10px;
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>