细节优化——1
This commit is contained in:
@@ -44,53 +44,39 @@
|
||||
</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>
|
||||
<ThreadComposer
|
||||
v-model="replyContent"
|
||||
v-model:file-list="replyFiles"
|
||||
:submitting="submitting"
|
||||
:quote-item="quoteReply"
|
||||
:member-map="memberMap"
|
||||
:user-map="userMap"
|
||||
:allow-attachments="true"
|
||||
@submit="submitReply"
|
||||
@clear-quote="clearQuote"
|
||||
/>
|
||||
</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>
|
||||
<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">最佳答案</el-tag>
|
||||
<el-button v-if="canSelectBest && !r.is_deleted" type="text" size="small" @click="toggleBest(r)">
|
||||
{{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</ThreadList>
|
||||
<div v-else class="reply-empty">暂无回复</div>
|
||||
</el-card>
|
||||
|
||||
@@ -107,6 +93,7 @@
|
||||
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,
|
||||
@@ -117,10 +104,13 @@ import {
|
||||
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 { usePermission } from "../utils/permission";
|
||||
import FaqItemForm from "../components/FaqItemForm.vue";
|
||||
import ThreadComposer from "../components/ThreadComposer.vue";
|
||||
import ThreadList from "../components/ThreadList.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
@@ -135,12 +125,13 @@ 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 canReply = computed(() => {
|
||||
if (!item.value) return false;
|
||||
if (!item.value.study_id) return auth.user?.role === "ADMIN";
|
||||
return can("faq.reply");
|
||||
});
|
||||
|
||||
@@ -153,7 +144,6 @@ const canDeleteReply = (reply: any) => {
|
||||
|
||||
const canSelectBest = computed(() => {
|
||||
if (!item.value) return false;
|
||||
if (!item.value.study_id) return auth.user?.role === "ADMIN";
|
||||
return can("faq.reply");
|
||||
});
|
||||
|
||||
@@ -161,7 +151,7 @@ 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;
|
||||
return can("faq.edit");
|
||||
});
|
||||
|
||||
const canConfirmResolved = computed(() => {
|
||||
@@ -169,7 +159,7 @@ const canConfirmResolved = computed(() => {
|
||||
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;
|
||||
return can("faq.edit");
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
@@ -182,6 +172,7 @@ 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) => {
|
||||
@@ -214,6 +205,7 @@ const categoryName = computed(() => {
|
||||
return cat ? cat.name : item.value?.category_id;
|
||||
});
|
||||
|
||||
|
||||
const loadMembers = async (studyId?: string | null) => {
|
||||
if (!studyId) {
|
||||
members.value = [];
|
||||
@@ -233,6 +225,7 @@ const loadReplies = async () => {
|
||||
try {
|
||||
const { data } = await fetchFaqReplies(id);
|
||||
replies.value = Array.isArray(data) ? data : data.items || [];
|
||||
await loadReplyAttachments();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "回复加载失败");
|
||||
} finally {
|
||||
@@ -249,7 +242,6 @@ const loadData = async () => {
|
||||
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 || [];
|
||||
@@ -277,13 +269,25 @@ const submitReply = async () => {
|
||||
if (!item.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await createFaqReply(item.value.id, {
|
||||
const { data } = await createFaqReply(item.value.id, {
|
||||
content: replyContent.value,
|
||||
quote_reply_id: quoteReply.value?.id || null,
|
||||
});
|
||||
const created = data as any;
|
||||
if (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 || "附件上传失败");
|
||||
}
|
||||
}
|
||||
ElMessage.success("回复已提交");
|
||||
replyContent.value = "";
|
||||
quoteReply.value = null;
|
||||
replyFiles.value = [];
|
||||
loadReplies();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "回复失败");
|
||||
@@ -342,6 +346,29 @@ const onEditSuccess = () => {
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user