Step F10:FAQ / 知识库

This commit is contained in:
Cheng Zhou
2025-12-17 14:19:30 +08:00
parent a10590ef8d
commit 580d64602f
24 changed files with 669 additions and 2 deletions
+47
View File
@@ -0,0 +1,47 @@
import { apiGet, apiPatch, apiPost } from "./axios";
import type { AxiosResponse } from "axios";
import type { ApiListResponse } from "../types/api";
export interface FaqCategory {
id: string;
study_id?: string | null;
name: string;
description?: string | null;
sort_order: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface FaqItem {
id: string;
study_id?: string | null;
category_id: string;
question: string;
answer: string;
keywords?: string | null;
version: number;
is_active: boolean;
created_by?: string;
created_at: string;
updated_at: string;
}
export const fetchFaqCategories = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqCategory>>> =>
apiGet("/api/v1/faqs/categories", { params });
export const createFaqCategory = (payload: Record<string, any>) =>
apiPost<FaqCategory>("/api/v1/faqs/categories", payload);
export const updateFaqCategory = (categoryId: string, payload: Record<string, any>) =>
apiPatch<FaqCategory>(`/api/v1/faqs/categories/${categoryId}`, payload);
export const fetchFaqItems = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqItem>>> =>
apiGet("/api/v1/faqs/items", { params });
export const fetchFaqItem = (itemId: string) => apiGet<FaqItem>(`/api/v1/faqs/items/${itemId}`);
export const createFaqItem = (payload: Record<string, any>) => apiPost<FaqItem>("/api/v1/faqs/items", payload);
export const updateFaqItem = (itemId: string, payload: Record<string, any>) =>
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}`, payload);
+106
View File
@@ -0,0 +1,106 @@
<template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑分类' : '新增分类'" width="480px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" :step="1" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { createFaqCategory, updateFaqCategory } from "../api/faqs";
import { useStudyStore } from "../store/study";
const props = defineProps<{ modelValue: boolean; category?: any; isAdmin: boolean }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
study_id: "",
name: "",
description: "",
sort_order: 0,
is_active: true,
});
const rules: FormRules = {
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
};
const isEdit = computed(() => !!props.category);
const reset = () => {
form.study_id = props.isAdmin ? "" : study.currentStudy?.id || "";
form.name = "";
form.description = "";
form.sort_order = 0;
form.is_active = true;
};
watch(
() => props.category,
(val) => {
if (val) {
form.study_id = val.study_id || "";
form.name = val.name;
form.description = val.description || "";
form.sort_order = val.sort_order || 0;
form.is_active = val.is_active;
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
const payload: Record<string, any> = {
study_id: form.study_id || null,
name: form.name,
description: form.description || null,
sort_order: form.sort_order,
is_active: form.is_active,
};
if (!props.isAdmin) {
payload.study_id = study.currentStudy?.id || null;
}
if (isEdit.value && props.category) {
await updateFaqCategory(props.category.id, payload);
} else {
await createFaqCategory(payload);
}
ElMessage.success("保存成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
</script>
@@ -0,0 +1,87 @@
<template>
<el-card class="panel">
<div class="header">
<span>分类</span>
<div v-if="canMaintain">
<el-button type="primary" size="small" @click="openForm()">新建</el-button>
</div>
</div>
<el-menu :default-active="activeCategory" @select="onSelect">
<el-menu-item index="">全部</el-menu-item>
<el-menu-item v-for="c in sortedCategories" :key="c.id" :index="c.id">
<span>{{ c.name }}</span>
<el-tag v-if="c.study_id" size="small" type="success" class="ml-4">项目</el-tag>
<el-tag v-else size="small" class="ml-4">全局</el-tag>
<el-button
v-if="canEdit(c)"
type="text"
size="small"
style="margin-left: auto"
@click.stop="openForm(c)"
>
编辑
</el-button>
</el-menu-item>
</el-menu>
<FaqCategoryForm v-model="showForm" :category="editing" :is-admin="isAdmin" @success="emit('refresh')" />
</el-card>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import FaqCategoryForm from "./FaqCategoryForm.vue";
import type { FaqCategory } from "../api/faqs";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
const props = defineProps<{
categories: FaqCategory[];
modelValue: string;
}>();
const emit = defineEmits(["update:modelValue", "refresh"]);
const auth = useAuthStore();
const study = useStudyStore();
const showForm = ref(false);
const editing = ref<FaqCategory | null>(null);
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const canMaintain = computed(() => isAdmin.value || auth.user?.role === "PM");
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 (isAdmin.value) return true;
return auth.user?.role === "PM" && c.study_id === study.currentStudy?.id;
};
const onSelect = (id: string) => {
emit("update:modelValue", id);
};
const openForm = (cat?: FaqCategory) => {
editing.value = cat || null;
showForm.value = true;
};
</script>
<style scoped>
.panel {
padding: 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid #ebeef5;
}
.ml-4 {
margin-left: 4px;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<el-dialog :model-value="modelValue" :title="isEdit ? '编辑 FAQ' : '新建 FAQ'" width="640px" @close="close">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="请选择分类">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="问题" prop="question">
<el-input v-model="form.question" />
</el-form-item>
<el-form-item label="答案" prop="answer">
<el-input v-model="form.answer" type="textarea" :rows="6" />
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="form.keywords" placeholder="逗号分隔,可选" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, FormInstance, FormRules } from "element-plus";
import { createFaqItem, updateFaqItem } from "../api/faqs";
import { useStudyStore } from "../store/study";
const props = defineProps<{ modelValue: boolean; item?: any; categories: any[] }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
study_id: "",
category_id: "",
question: "",
answer: "",
keywords: "",
is_active: true,
});
const rules: FormRules = {
category_id: [{ required: true, message: "请选择分类", trigger: "change" }],
question: [{ required: true, message: "请输入问题", trigger: "blur" }],
answer: [{ required: true, message: "请输入答案", trigger: "blur" }],
};
const isEdit = computed(() => !!props.item);
const reset = () => {
form.study_id = study.currentStudy?.id || "";
form.category_id = "";
form.question = "";
form.answer = "";
form.keywords = "";
form.is_active = true;
};
watch(
() => props.item,
(val) => {
if (val) {
form.study_id = val.study_id || "";
form.category_id = val.category_id;
form.question = val.question;
form.answer = val.answer;
form.keywords = val.keywords || "";
form.is_active = val.is_active;
} else {
reset();
}
},
{ immediate: true }
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
const payload: Record<string, any> = {
study_id: form.study_id || null,
category_id: form.category_id,
question: form.question,
answer: form.answer,
keywords: form.keywords || null,
is_active: form.is_active,
};
if (isEdit.value && props.item) {
await updateFaqItem(props.item.id, payload);
} else {
await createFaqItem(payload);
}
ElMessage.success("保存成功");
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
</script>
+78
View File
@@ -0,0 +1,78 @@
<template>
<el-table :data="items" v-loading="loading" style="width: 100%">
<el-table-column prop="question" label="问题" min-width="200" />
<el-table-column prop="category_id" label="分类" width="140">
<template #default="scope">
{{ categoryMap[scope.row.category_id] || scope.row.category_id }}
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="80" />
<el-table-column prop="is_active" label="启用" width="80">
<template #default="scope">
<el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" />
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button type="text" size="small" @click="view(scope.row)">查看</el-button>
<el-button v-if="canEdit" type="text" size="small" @click="edit(scope.row)">编辑</el-button>
<el-button
v-if="canEdit"
type="text"
size="small"
@click="toggle(scope.row)"
:style="{ color: scope.row.is_active ? '#f56c6c' : '#67c23a' }"
>
{{ scope.row.is_active ? "停用" : "启用" }}
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { updateFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs";
const props = defineProps<{
items: FaqItem[];
categories: FaqCategory[];
loading: boolean;
canEdit: boolean;
}>();
const emit = defineEmits(["refresh", "edit"]);
const router = useRouter();
const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.name;
return acc;
}, {})
);
const view = (row: FaqItem) => {
router.push(`/study/faq/${row.id}`);
};
const edit = (row: FaqItem) => {
emit("edit", row);
};
const toggle = async (row: FaqItem) => {
const target = !row.is_active;
const ok = await ElMessageBox.confirm(`确认${target ? "启用" : "停用"}此 FAQ?`, "提示").catch(() => null);
if (!ok) return;
try {
await updateFaqItem(row.id, { is_active: target });
ElMessage.success("操作成功");
emit("refresh");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
};
</script>
+7
View File
@@ -22,6 +22,7 @@ import ImpTransactions from "../views/ImpTransactions.vue";
import Finance from "../views/Finance.vue";
import FinanceDetail from "../views/FinanceDetail.vue";
import Faq from "../views/Faq.vue";
import FaqDetail from "../views/FaqDetail.vue";
import Issues from "../views/Issues.vue";
import IssueDetail from "../views/IssueDetail.vue";
import Verification from "../views/Verification.vue";
@@ -158,6 +159,12 @@ const routes: RouteRecordRaw[] = [
component: Faq,
meta: { title: "FAQ", requiresStudy: true },
},
{
path: "study/faq/:itemId",
name: "StudyFaqDetail",
component: FaqDetail,
meta: { title: "FAQ 详情", requiresStudy: true },
},
{
path: "study/verifications",
name: "StudyVerifications",
+152 -2
View File
@@ -1,13 +1,163 @@
<template>
<div class="page">
<el-card><h2>FAQ</h2></el-card>
<el-row :gutter="12">
<el-col :span="6">
<FaqCategoryPanel
v-model="activeCategory"
:categories="categories"
@refresh="loadCategories"
/>
</el-col>
<el-col :span="18">
<el-card class="mb-12">
<div class="filters">
<el-input
v-model="keyword"
placeholder="搜索关键词"
clearable
@keyup.enter="loadFaqs"
style="width: 260px"
/>
<el-select v-model="studyScope" placeholder="范围" clearable style="width: 160px" @change="loadFaqs">
<el-option label="项目 FAQ" value="project" />
<el-option label="全局 FAQ" value="global" />
<el-option label="全部" value="all" />
</el-select>
<el-switch v-model="onlyActive" active-text="仅启用" @change="loadFaqs" />
<div class="spacer" />
<el-button type="primary" v-if="canEdit" @click="openForm()">新建 FAQ</el-button>
</div>
</el-card>
<FaqList
:items="faqs"
:categories="categories"
:loading="loading"
:can-edit="canEdit"
@refresh="loadFaqs"
@edit="openForm"
/>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-col>
</el-row>
<FaqItemForm v-model="showForm" :item="editing" :categories="categories" @success="loadFaqs" />
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
const auth = useAuthStore();
const study = useStudyStore();
const categories = ref<any[]>([]);
const faqs = ref<any[]>([]);
const loading = ref(false);
const page = ref(1);
const pageSize = 10;
const total = ref(0);
const keyword = ref("");
const onlyActive = ref(true);
const studyScope = ref("project");
const activeCategory = ref("");
const showForm = ref(false);
const editing = ref<any | null>(null);
const canEdit = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM";
});
const loadCategories = async () => {
try {
const params: Record<string, any> = {};
if (study.currentStudy) params.study_id = study.currentStudy.id;
const { data } = await fetchFaqCategories(params);
categories.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "分类加载失败");
}
};
const loadFaqs = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
study_id: study.currentStudy.id,
};
if (activeCategory.value) params.category_id = activeCategory.value;
if (keyword.value) params.keyword = keyword.value;
if (onlyActive.value) params.is_active = true;
if (studyScope.value) params.study_scope = studyScope.value;
const { data } = await fetchFaqItems(params);
if (Array.isArray(data)) {
faqs.value = data;
total.value = data.length;
} else {
faqs.value = data.items || [];
total.value = data.total || 0;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "FAQ 加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
loadFaqs();
};
const openForm = (row?: any) => {
editing.value = row || null;
showForm.value = true;
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadCategories();
loadFaqs();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="page">
<el-card v-loading="loading">
<div class="header">
<div>
<h3>{{ item?.question }}</h3>
<el-tag size="small">{{ item?.version ? "v" + item.version : "" }}</el-tag>
</div>
<div>
<el-tag type="info">{{ categoryName }}</el-tag>
<el-tag :type="item?.is_active ? 'success' : 'info'" class="ml-8">
{{ item?.is_active ? "启用" : "停用" }}
</el-tag>
</div>
</div>
<el-divider />
<div class="content">
<pre>{{ item?.answer }}</pre>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchFaqItem, fetchFaqCategories } from "../api/faqs";
const route = useRoute();
const item = ref<any>(null);
const loading = ref(false);
const categories = ref<any[]>([]);
const categoryName = computed(() => {
const cat = categories.value.find((c) => c.id === item.value?.category_id);
return cat ? cat.name : item.value?.category_id;
});
const loadData = async () => {
const id = route.params.itemId as string;
loading.value = true;
try {
const [{ data: catData }, { data: faqData }] = await Promise.all([
fetchFaqCategories(),
fetchFaqItem(id),
]);
categories.value = catData.items || catData || [];
item.value = faqData;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败");
} finally {
loading.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.content {
white-space: pre-wrap;
line-height: 1.6;
}
.ml-8 {
margin-left: 8px;
}
</style>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.