feat(faq): refine category icons and project faq flows

This commit is contained in:
Cheng Zhou
2026-06-08 10:59:32 +08:00
parent f6e5a4c744
commit e5ed18c3cd
18 changed files with 1375 additions and 214 deletions
+222 -29
View File
@@ -1,27 +1,45 @@
<template>
<div class="page ctms-page-shell page--flush">
<el-row :gutter="12">
<el-col v-if="canReadCategories" :span="5">
<div class="page ctms-page-shell page--flush medical-consult-page">
<div class="page-bg-dots"></div>
<section class="faq-hero">
<h1><i class="fas fa-book-medical hero-title-icon"></i>项目知识库</h1>
<div class="hero-tools">
<el-autocomplete
v-model="keyword"
:placeholder="TEXT.common.placeholders.keyword"
clearable
class="hero-search"
:fetch-suggestions="fetchSuggestions"
:trigger-on-focus="false"
highlight-first-item
@select="onSuggestionSelect"
@keyup.enter="loadFaqs"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
<div class="spacer" />
</div>
</section>
<el-row :gutter="0" class="faq-workspace">
<el-col v-if="canReadCategories" :span="5" class="faq-sidebar-col">
<FaqCategoryPanel
v-model="activeCategory"
:categories="categories"
:faqs="faqs"
@refresh="loadCategories"
/>
</el-col>
<el-col :span="canReadCategories ? 19 : 24">
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell">
<div class="filters unified-action-bar">
<el-input
v-model="keyword"
:placeholder="TEXT.common.placeholders.keyword"
clearable
@keyup.enter="loadFaqs"
style="width: 260px"
/>
<el-switch v-model="onlyActive" :active-text="TEXT.common.labels.activeOnly" @change="loadFaqs" />
<div class="spacer" />
<div class="list-toolbar">
<PermissionAction action="faq.create">
<el-button type="primary" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newItem }}</el-button>
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
</div>
@@ -56,6 +74,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus, Search } from "@element-plus/icons-vue";
import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue";
@@ -76,7 +95,7 @@ const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const keyword = ref("");
const onlyActive = ref(true);
const activeCategory = ref("");
const showForm = ref(false);
const editing = ref<any | null>(null);
@@ -114,7 +133,6 @@ const loadFaqs = async () => {
};
if (activeCategory.value) params.category_id = activeCategory.value;
if (keyword.value) params.keyword = keyword.value;
if (onlyActive.value) params.is_active = true;
const { data } = await fetchFaqItems(params);
if (Array.isArray(data)) {
faqs.value = data;
@@ -130,6 +148,30 @@ const loadFaqs = async () => {
}
};
const fetchSuggestions = async (queryString: string, callback: (results: Array<{ value: string }>) => void) => {
if (!queryString || !study.currentStudy) {
callback([]);
return;
}
try {
const params: Record<string, any> = {
study_id: study.currentStudy.id,
keyword: queryString,
limit: 8,
};
const { data } = await fetchFaqItems(params);
const items = Array.isArray(data) ? data : data.items || [];
callback(items.slice(0, 8).map((item: any) => ({ value: item.question || item.title || "" })));
} catch {
callback([]);
}
};
const onSuggestionSelect = (item: { value: string }) => {
keyword.value = item.value;
loadFaqs();
};
const onPageChange = (p: number) => {
page.value = p;
loadFaqs();
@@ -162,26 +204,177 @@ onMounted(async () => {
<style scoped>
.page {
position: relative;
padding: 0;
min-height: calc(100vh - 64px);
background: linear-gradient(180deg, #f0f4ff 0%, #ffffff 30%, #f8fafc 100%);
}
.faq-main {
background: #fff;
border: 0;
border-radius: 0;
padding: 0;
.page-bg-dots {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: radial-gradient(#1e40af 1px, transparent 1px);
background-size: 24px 24px;
}
.faq-hero {
position: relative;
padding: 28px 24px 36px;
text-align: center;
border-bottom: 1px solid #e8eef6;
background:
radial-gradient(ellipse at 50% 0%, rgba(59, 130, 246, 0.06) 0%, transparent 60%),
linear-gradient(180deg, #ffffff 0%, #fafcff 100%);
overflow: hidden;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
.faq-hero::before {
content: "";
position: absolute;
top: -80px;
left: 50%;
transform: translateX(-50%);
width: 400px;
height: 200px;
background: radial-gradient(ellipse, rgba(59, 130, 246, 0.08) 0%, transparent 70%);
pointer-events: none;
}
.spacer {
flex: 1;
.faq-hero h1 {
margin: 0 0 24px;
color: #0f172a;
font-size: 32px;
font-weight: 900;
letter-spacing: -0.02em;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.hero-title-icon {
font-size: 28px;
color: #3b82f6;
opacity: 0.85;
}
.hero-desc {
margin: 8px 0 28px;
color: #64748b;
font-size: 15px;
font-weight: 500;
}
.hero-tools {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
max-width: 920px;
margin: 0 auto;
}
.hero-search {
width: min(620px, 54vw);
}
.hero-search :deep(.el-input__wrapper) {
height: 54px;
padding: 0 20px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14px 34px rgba(15, 35, 69, 0.08), 0 0 0 1px #e6edf6 inset;
}
.hero-search :deep(.el-input__inner) {
color: #1f2d3d;
font-size: 16px;
font-weight: 600;
}
.hero-search :deep(.el-input__prefix) {
color: #9aa9bb;
font-size: 20px;
}
.new-consult-btn {
flex-shrink: 0;
height: 46px;
padding: 0 20px;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
font-weight: 800;
font-size: 14px;
transition: all 0.2s ease;
}
.new-consult-btn:hover,
.new-consult-btn:focus {
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4);
}
.faq-workspace {
min-height: calc(100vh - 252px);
}
.faq-sidebar-col {
border-right: 1px solid #edf2f8;
background: rgba(255, 255, 255, 0.72);
}
.faq-content-col {
min-width: 0;
}
.faq-main {
min-height: calc(100vh - 252px);
background: transparent;
border: 0;
border-radius: 0;
padding: 24px 28px 0;
overflow: hidden;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 0 16px;
}
.list-title strong {
min-width: 30px;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background: #e8f2ff;
color: #1f5fb8;
font-size: 13px;
line-height: 24px;
}
.list-meta {
color: #7d8da3;
font-size: 13px;
font-weight: 700;
}
.pagination-wrap {
padding: 10px 16px 0;
padding: 16px 0 24px;
display: flex;
justify-content: flex-end;
}
.spacer {
flex: 1;
}
@media (max-width: 1080px) {
.hero-tools {
flex-wrap: wrap;
}
.hero-search {
width: 100%;
}
.faq-sidebar-col,
.faq-content-col {
max-width: 100%;
flex: 0 0 100%;
}
.faq-main {
padding: 18px 16px 0;
}
}
</style>
+15 -7
View File
@@ -235,7 +235,7 @@ const loadReplies = async () => {
const id = route.params.itemId as string;
repliesLoading.value = true;
try {
const { data } = await fetchFaqReplies(id);
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) {
@@ -249,7 +249,7 @@ const loadData = async () => {
const id = route.params.itemId as string;
loading.value = true;
try {
const { data: faqData } = await fetchFaqItem(id);
const { data: faqData } = await fetchFaqItem(id, study.currentStudy?.id);
item.value = faqData;
const questionTitle = String(faqData.question || "").trim();
study.setViewContext({
@@ -288,7 +288,7 @@ const submitReply = async () => {
if (!item.value) return;
submitting.value = true;
try {
const { data } = await createFaqReply(item.value.id, {
const { data } = await createFaqReply(item.value.id, item.value.study_id, {
content: replyContent.value,
quote_reply_id: quoteReply.value?.id || null,
});
@@ -324,7 +324,7 @@ const removeReply = async (reply: any) => {
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);
await deleteFaqReply(item.value.id, reply.id, item.value.study_id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
loadReplies();
} catch (e: any) {
@@ -344,7 +344,7 @@ const toggleBest = async (reply: any) => {
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, { best_reply_id: target });
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) {
@@ -359,7 +359,7 @@ const confirmResolved = async () => {
return;
}
try {
const { data } = await setFaqStatus(item.value.id, { status: "RESOLVED" });
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) {
@@ -413,6 +413,14 @@ onMounted(() => {
.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;
@@ -548,7 +556,7 @@ onMounted(() => {
.question-text {
margin: 6px 0 0;
padding: 12px 14px;
border-radius: 10px;
border-radius: 8px;
background: #f5f7ff;
border: 1px solid #e0e7ff;
color: #1f2a44;
@@ -37,6 +37,16 @@ describe("FAQ project permissions", () => {
expect(source).not.toContain("row.created_by === auth.user?.id");
});
it("opens FAQ detail from any row cell while preserving action button click isolation", () => {
const source = read("../components/FaqList.vue");
expect(source).toContain('@row-click="onRowClick"');
expect(source).toContain("const onRowClick = (row: FaqItem) =>");
expect(source).not.toContain('column?.property !== "question"');
expect(source).toContain('@click.stop="remove(scope.row)"');
expect(source).toContain('@click.stop="toggle(scope.row)"');
});
it("splits FAQ category create update and delete controls by backend operation permissions", () => {
const source = read("../components/FaqCategoryPanel.vue");