细节优化——1

This commit is contained in:
Cheng Zhou
2025-12-30 14:07:57 +08:00
parent 71db309e12
commit 0c1fc49f17
40 changed files with 1610 additions and 389 deletions
+77 -50
View File
@@ -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();
});