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
+115 -57
View File
@@ -1,45 +1,42 @@
<template>
<div class="panel unified-shell">
<aside class="panel unified-shell faq-category-panel">
<div class="header">
<span>{{ TEXT.common.labels.category }}</span>
<div v-if="canCreateCategory">
<PermissionAction action="faq.category.create">
<el-button type="primary" size="small" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newCategory }}</el-button>
</PermissionAction>
<el-button type="primary" size="small" class="category-create-btn" @click="openManager()">编辑</el-button>
</div>
</div>
<el-menu :default-active="activeCategory" @select="onSelect" class="menu">
<el-menu-item index="">{{ TEXT.common.labels.all }}</el-menu-item>
<el-menu-item index="">
<span class="cat-icon"><i class="fas fa-th-large"></i></span>
<span class="name">{{ TEXT.common.labels.all }}</span>
<span class="cat-count">{{ totalFaqCount }}</span>
</el-menu-item>
<el-menu-item v-for="c in sortedCategories" :key="c.id" :index="c.id">
<span class="cat-icon">
<i v-if="c.icon" :class="['fas', c.icon]"></i>
<span v-else>{{ c.name.slice(0, 1) }}</span>
</span>
<span class="name" :title="c.name">{{ c.name }}</span>
<div v-if="canEdit(c) || canDeleteCategory" class="actions">
<PermissionAction v-if="canEdit(c)" action="faq.category.update">
<el-button type="text" size="small" @click.stop="openForm(c)">{{ TEXT.common.actions.edit }}</el-button>
</PermissionAction>
<PermissionAction v-if="canDeleteCategory" action="faq.category.delete">
<el-button type="text" size="small" class="danger" @click.stop="onDelete(c)">{{ TEXT.common.actions.delete }}</el-button>
</PermissionAction>
</div>
<span class="cat-count">{{ categoryCounts[c.id] || 0 }}</span>
</el-menu-item>
</el-menu>
<FaqCategoryForm v-model="showForm" :category="editing" :is-admin="isAdmin" @success="emit('refresh')" />
</div>
<FaqCategoryManager v-model="showForm" :categories="props.categories" :faqs="props.faqs" @success="emit('refresh')" />
</aside>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FaqCategoryForm from "./FaqCategoryForm.vue";
import FaqCategoryManager from "./FaqCategoryManager.vue";
import type { FaqCategory } from "../api/faqs";
import { deleteFaqCategory } from "../api/faqs";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import PermissionAction from "./PermissionAction.vue";
import { usePermission } from "../utils/permission";
import { TEXT } from "../locales";
const props = defineProps<{
categories: FaqCategory[];
faqs?: any[];
modelValue: string;
}>();
const emit = defineEmits(["update:modelValue", "refresh"]);
@@ -48,52 +45,43 @@ const auth = useAuthStore();
const study = useStudyStore();
const showForm = ref(false);
const editing = ref<FaqCategory | null>(null);
const { can } = usePermission();
const isAdmin = computed(() => !!auth.user?.is_admin);
const canCreateCategory = computed(() => can("faq.category.create"));
const canUpdateCategory = computed(() => can("faq.category.update"));
const canDeleteCategory = computed(() => can("faq.category.delete"));
const sortedCategories = computed(() =>
[...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
);
const activeCategory = computed(() => props.modelValue || "");
const canEdit = (c: FaqCategory) => {
if (!canUpdateCategory.value) return false;
if (isAdmin.value) return true;
return c.study_id === study.currentStudy?.id;
};
const categoryCounts = computed(() => {
const map: Record<string, number> = {};
(props.faqs || []).forEach((f: any) => {
if (f.category_id) {
map[f.category_id] = (map[f.category_id] || 0) + 1;
}
});
return map;
});
const totalFaqCount = computed(() => (props.faqs || []).length);
const onSelect = (id: string) => {
if (id && id === activeCategory.value) {
openManager();
return;
}
emit("update:modelValue", id);
};
const openForm = (cat?: FaqCategory) => {
editing.value = cat || null;
showForm.value = true;
};
const activeIcons = (excludeId?: string) =>
sortedCategories.value
.filter((c) => c.id !== excludeId && c.icon)
.map((c) => c.icon as string);
const onDelete = async (cat: FaqCategory) => {
if (!canDeleteCategory.value) {
ElMessage.warning("权限不足");
return;
}
const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmDeleteCategory.replace("{name}", cat.name),
TEXT.common.labels.tips
).catch(() => null);
if (!ok) return;
try {
await deleteFaqCategory(cat.id, study.currentStudy?.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
emit("refresh");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
const openManager = () => {
showForm.value = true;
};
</script>
@@ -103,26 +91,79 @@ const onDelete = async (cat: FaqCategory) => {
border-radius: 0;
box-shadow: none;
overflow: hidden;
padding: 24px 14px;
background: transparent;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 12px;
border-bottom: 1px solid #ebeef5;
gap: 10px;
padding: 0 8px 16px;
border-bottom: 1px solid #edf2f8;
color: #8b98aa;
font-size: 14px;
font-weight: 800;
}
.menu {
max-height: calc(100vh - 240px);
overflow: auto;
border-right: 0;
padding: 14px 0 0;
background: transparent;
}
.actions {
margin-left: auto;
display: flex;
.menu :deep(.el-menu-item) {
height: 44px;
margin: 4px 0;
padding: 0 10px !important;
border-radius: 10px;
color: #5f6f7a;
font-size: 14px;
font-weight: 700;
line-height: 44px;
}
.menu :deep(.el-menu-item:hover) {
background: rgba(31, 95, 184, 0.06);
color: #1f5fb8;
}
.menu :deep(.el-menu-item.is-active) {
background: #e7f4ff;
color: #1f5fb8;
}
.cat-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
justify-content: center;
width: 24px;
height: 24px;
margin-right: 10px;
border-radius: 7px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
font-size: 13px;
font-weight: 800;
}
.cat-icon i {
font-size: 13px;
}
.menu :deep(.el-menu-item.is-active) .cat-icon {
background: #d7ebff;
color: #1f5fb8;
}
.category-create-btn {
height: 34px;
border: 0;
border-radius: 10px;
background: #415a77;
font-weight: 800;
box-shadow: 0 8px 18px rgba(65, 90, 119, 0.16);
}
.category-create-btn:hover,
.category-create-btn:focus {
background: #344960;
}
.name {
flex: 1;
@@ -131,7 +172,24 @@ const onDelete = async (cat: FaqCategory) => {
text-overflow: ellipsis;
white-space: nowrap;
}
.danger {
color: #f56c6c;
.cat-count {
flex-shrink: 0;
margin-left: 6px;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
font-size: 10px;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.menu :deep(.el-menu-item.is-active) .cat-count {
background: #d7ebff;
color: #1f5fb8;
}
</style>