From e5ed18c3cdec4c4437b133139f9793f9b9a092c0 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 8 Jun 2026 10:59:32 +0800 Subject: [PATCH] feat(faq): refine category icons and project faq flows --- backend/app/api/v1/faq_categories.py | 11 +- backend/app/api/v1/faqs.py | 21 - backend/app/crud/faq_category.py | 21 +- backend/app/crud/faq_item.py | 4 - backend/app/models/faq_category.py | 4 +- backend/app/models/faq_item.py | 1 - backend/app/schemas/faq.py | 8 +- frontend/index.html | 1 + frontend/src/api/faqs.test.ts | 32 + frontend/src/api/faqs.ts | 26 +- frontend/src/components/FaqCategoryForm.vue | 140 +++- .../src/components/FaqCategoryManager.vue | 642 ++++++++++++++++++ frontend/src/components/FaqCategoryPanel.vue | 172 +++-- frontend/src/components/FaqItemForm.vue | 7 - frontend/src/components/FaqList.vue | 216 ++++-- frontend/src/views/Faq.vue | 251 ++++++- frontend/src/views/FaqDetail.vue | 22 +- .../views/FaqPermissionConsistency.test.ts | 10 + 18 files changed, 1375 insertions(+), 214 deletions(-) create mode 100644 frontend/src/components/FaqCategoryManager.vue diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index 907541c3..4523beaa 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -42,6 +42,10 @@ async def create_category( existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name) if existing: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") + if payload.icon: + dup = await category_crud.get_category_by_icon(db, payload.study_id, payload.icon) + if dup: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用") category = await category_crud.create_category(db, payload) await audit_crud.log_action( db, @@ -65,13 +69,12 @@ async def create_category( ) async def list_categories( study_id: uuid.UUID | None = None, - is_active: bool | None = True, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[CategoryRead]: if not study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") - categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active) + categories = await category_crud.list_categories(db, study_id, include_global=False) return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories)) @@ -102,6 +105,10 @@ async def update_category( existing = await category_crud.get_category_by_name(db, target_study_id, target_name) if existing and existing.id != category.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在") + if payload.icon: + dup = await category_crud.get_category_by_icon(db, target_study_id, payload.icon) + if dup and dup.id != category.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用") if not target_study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") updated = await category_crud.update_category(db, category, payload) diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index 72fed2e8..b36f1c49 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -90,7 +90,6 @@ async def list_faqs( study_id: uuid.UUID | None = None, category_id: uuid.UUID | None = None, keyword: str | None = None, - is_active: bool | None = None, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[FaqRead]: @@ -111,17 +110,12 @@ async def list_faqs( member = await member_crud.get_member(db, study_id, current_user.id) if not member or not member.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") - if is_active is False: - allowed = await role_has_api_permission(db, study_id, member.role_in_study, "faq:update", check_prerequisites=False) - if not allowed: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") items = await faq_crud.list_items( db, study_id=study_id, category_id=category_id, keyword=keyword, - is_active=is_active, study_scope="project", ) @@ -131,10 +125,6 @@ async def list_faqs( role = await _get_member_role(it.study_id) if not role: continue - if not it.is_active and not _is_system_admin(current_user): - allowed = await role_has_api_permission(db, it.study_id, role, "faq:update", check_prerequisites=False) - if not allowed: - continue visible.append(FaqRead.model_validate(it)) return paginate(visible, total=len(visible)) @@ -156,13 +146,6 @@ async def get_faq( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") if not item.study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") - if not item.is_active and not is_system_admin(current_user): - member = await member_crud.get_member(db, item.study_id, current_user.id) - if not member or not member.is_active: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") - allowed = await role_has_api_permission(db, item.study_id, member.role_in_study, "faq:update", check_prerequisites=False) - if not allowed: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") return FaqRead.model_validate(item) @@ -185,13 +168,9 @@ async def update_faq( item = await faq_crud.get_item(db, item_id) if not item: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") - old_active = item.is_active updated = await faq_crud.update_item(db, item, payload) action = "UPDATE_FAQ_ITEM" detail = "FAQ updated" - if payload.is_active is not None and payload.is_active != old_active: - action = "FAQ_STATUS_CHANGE" - detail = "FAQ disabled" if not payload.is_active else "FAQ enabled" await audit_crud.log_action( db, study_id=item.study_id, diff --git a/backend/app/crud/faq_category.py b/backend/app/crud/faq_category.py index 5b90274a..05f4b67b 100644 --- a/backend/app/crud/faq_category.py +++ b/backend/app/crud/faq_category.py @@ -14,7 +14,7 @@ async def create_category(db: AsyncSession, category_in: CategoryCreate) -> FaqC name=category_in.name, description=category_in.description, sort_order=category_in.sort_order, - is_active=category_in.is_active, + icon=category_in.icon, ) db.add(category) await db.commit() @@ -26,15 +26,12 @@ async def list_categories( db: AsyncSession, study_id: uuid.UUID | None, include_global: bool = True, - is_active: bool | None = True, ) -> Sequence[FaqCategory]: stmt = select(FaqCategory) if include_global: stmt = stmt.where((FaqCategory.study_id == study_id) | (FaqCategory.study_id.is_(None))) else: stmt = stmt.where(FaqCategory.study_id == study_id) - if is_active is not None: - stmt = stmt.where(FaqCategory.is_active == is_active) stmt = stmt.order_by(FaqCategory.sort_order, FaqCategory.name) result = await db.execute(stmt) return result.scalars().all() @@ -59,6 +56,22 @@ async def get_category_by_name( return result.scalar_one_or_none() +async def get_category_by_icon( + db: AsyncSession, + study_id: uuid.UUID | None, + icon: str, +) -> FaqCategory | None: + if not icon: + return None + stmt = select(FaqCategory).where(FaqCategory.icon == icon) + if study_id is None: + stmt = stmt.where(FaqCategory.study_id.is_(None)) + else: + stmt = stmt.where(FaqCategory.study_id == study_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def update_category(db: AsyncSession, category: FaqCategory, category_in: CategoryUpdate) -> FaqCategory: update_data = category_in.model_dump(exclude_unset=True) if update_data: diff --git a/backend/app/crud/faq_item.py b/backend/app/crud/faq_item.py index 44d726d9..96a0531e 100644 --- a/backend/app/crud/faq_item.py +++ b/backend/app/crud/faq_item.py @@ -26,7 +26,6 @@ async def create_item(db: AsyncSession, item_in: FaqCreate, *, created_by: uuid. answer=item_in.answer or "", status="PENDING", version=1, - is_active=True, created_by=created_by, ) db.add(item) @@ -46,7 +45,6 @@ async def list_items( study_id: uuid.UUID | None, category_id: uuid.UUID | None = None, keyword: str | None = None, - is_active: bool | None = True, study_scope: str | None = "project", ) -> Sequence[FaqItem]: stmt = select(FaqItem).join(FaqCategory, FaqCategory.id == FaqItem.category_id) @@ -69,8 +67,6 @@ async def list_items( FaqItem.keywords.ilike(like_pattern), ) ) - if is_active is not None: - stmt = stmt.where(FaqItem.is_active == is_active) result = await db.execute(stmt) return result.scalars().all() diff --git a/backend/app/models/faq_category.py b/backend/app/models/faq_category.py index ae20a19e..9bdd9eea 100644 --- a/backend/app/models/faq_category.py +++ b/backend/app/models/faq_category.py @@ -4,7 +4,7 @@ from typing import Optional import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -20,7 +20,7 @@ class FaqCategory(Base): name: Mapped[str] = mapped_column(String(100), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) sort_order: Mapped[int] = mapped_column(nullable=False, default=0) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + icon: Mapped[Optional[str]] = mapped_column(String(30), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() diff --git a/backend/app/models/faq_item.py b/backend/app/models/faq_item.py index eb948c3f..f7523752 100644 --- a/backend/app/models/faq_item.py +++ b/backend/app/models/faq_item.py @@ -26,7 +26,6 @@ class FaqItem(Base): answer: Mapped[str] = mapped_column(Text, nullable=False) keywords: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/schemas/faq.py b/backend/app/schemas/faq.py index 4d5624aa..06b5ffe4 100644 --- a/backend/app/schemas/faq.py +++ b/backend/app/schemas/faq.py @@ -10,7 +10,7 @@ class CategoryCreate(BaseModel): name: str description: Optional[str] = None sort_order: int = 0 - is_active: bool = True + icon: Optional[str] = None class CategoryUpdate(BaseModel): @@ -18,7 +18,7 @@ class CategoryUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None sort_order: Optional[int] = None - is_active: Optional[bool] = None + icon: Optional[str] = None class CategoryRead(BaseModel): @@ -27,7 +27,7 @@ class CategoryRead(BaseModel): name: str description: Optional[str] sort_order: int - is_active: bool + icon: Optional[str] created_at: datetime updated_at: datetime @@ -44,7 +44,6 @@ class FaqCreate(BaseModel): class FaqUpdate(BaseModel): question: Optional[str] = None answer: Optional[str] = None - is_active: Optional[bool] = None class FaqStatusUpdate(BaseModel): @@ -94,7 +93,6 @@ class FaqRead(BaseModel): answer: str keywords: Optional[str] version: int - is_active: bool created_by: uuid.UUID created_at: datetime updated_at: datetime diff --git a/frontend/index.html b/frontend/index.html index 00fa07c8..b6573b8e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,7 @@ + CTMS diff --git a/frontend/src/api/faqs.test.ts b/frontend/src/api/faqs.test.ts index cb130076..fe345620 100644 --- a/frontend/src/api/faqs.test.ts +++ b/frontend/src/api/faqs.test.ts @@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const apiPost = vi.fn(); const apiPatch = vi.fn(); const apiDelete = vi.fn(); +const apiGet = vi.fn(); vi.mock("./axios", () => ({ + apiGet, apiPost, apiPatch, apiDelete, @@ -54,4 +56,34 @@ describe("faqs item api", () => { params: { study_id: "study-1" }, }); }); + + it("passes study_id as a query parameter for item detail permission checks", async () => { + const { fetchFaqItem, fetchFaqReplies, createFaqReply, deleteFaqReply, setFaqBestReply, setFaqStatus } = await import("./faqs"); + + fetchFaqItem("item-1", "study-1"); + fetchFaqReplies("item-1", "study-1"); + createFaqReply("item-1", "study-1", { content: "建议复查。" }); + deleteFaqReply("item-1", "reply-1", "study-1"); + setFaqBestReply("item-1", "study-1", { best_reply_id: "reply-1" }); + setFaqStatus("item-1", "study-1", { status: "RESOLVED" }); + + expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", { + params: { study_id: "study-1" }, + }); + expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", { + params: { study_id: "study-1" }, + }); + expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", { content: "建议复查。" }, { + params: { study_id: "study-1" }, + }); + expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies/reply-1", { + params: { study_id: "study-1" }, + }); + expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/best-reply", { best_reply_id: "reply-1" }, { + params: { study_id: "study-1" }, + }); + expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/status", { status: "RESOLVED" }, { + params: { study_id: "study-1" }, + }); + }); }); diff --git a/frontend/src/api/faqs.ts b/frontend/src/api/faqs.ts index 1200ec9f..2946b716 100644 --- a/frontend/src/api/faqs.ts +++ b/frontend/src/api/faqs.ts @@ -8,7 +8,7 @@ export interface FaqCategory { name: string; description?: string | null; sort_order: number; - is_active: boolean; + icon?: string | null; created_at: string; updated_at: string; } @@ -23,7 +23,6 @@ export interface FaqItem { answer: string; keywords?: string | null; version: number; - is_active: boolean; created_by?: string; created_at: string; updated_at: string; @@ -65,7 +64,8 @@ export const deleteFaqCategory = (categoryId: string, studyId?: string | null) = export const fetchFaqItems = (params?: Record): Promise>> => apiGet("/api/v1/faqs/items/", { params }); -export const fetchFaqItem = (itemId: string) => apiGet(`/api/v1/faqs/items/${itemId}`); +export const fetchFaqItem = (itemId: string, studyId?: string | null) => + apiGet(`/api/v1/faqs/items/${itemId}`, studyQuery(studyId)); export const createFaqItem = (payload: Record) => apiPost("/api/v1/faqs/items/", payload, studyQuery(payload.study_id)); @@ -76,17 +76,17 @@ export const updateFaqItem = (itemId: string, payload: Record) => export const deleteFaqItem = (itemId: string, studyId?: string | null) => apiDelete(`/api/v1/faqs/items/${itemId}`, studyQuery(studyId)); -export const fetchFaqReplies = (itemId: string) => - apiGet(`/api/v1/faqs/items/${itemId}/replies`); +export const fetchFaqReplies = (itemId: string, studyId?: string | null) => + apiGet(`/api/v1/faqs/items/${itemId}/replies`, studyQuery(studyId)); -export const createFaqReply = (itemId: string, payload: { content: string; quote_reply_id?: string | null }) => - apiPost(`/api/v1/faqs/items/${itemId}/replies`, payload); +export const createFaqReply = (itemId: string, studyId: string | null | undefined, payload: { content: string; quote_reply_id?: string | null }) => + apiPost(`/api/v1/faqs/items/${itemId}/replies`, payload, studyQuery(studyId)); -export const deleteFaqReply = (itemId: string, replyId: string) => - apiDelete(`/api/v1/faqs/items/${itemId}/replies/${replyId}`); +export const deleteFaqReply = (itemId: string, replyId: string, studyId?: string | null) => + apiDelete(`/api/v1/faqs/items/${itemId}/replies/${replyId}`, studyQuery(studyId)); -export const setFaqBestReply = (itemId: string, payload: { best_reply_id: string | null }) => - apiPatch(`/api/v1/faqs/items/${itemId}/best-reply`, payload); +export const setFaqBestReply = (itemId: string, studyId: string | null | undefined, payload: { best_reply_id: string | null }) => + apiPatch(`/api/v1/faqs/items/${itemId}/best-reply`, payload, studyQuery(studyId)); -export const setFaqStatus = (itemId: string, payload: { status: "PENDING" | "PROCESSING" | "RESOLVED" }) => - apiPatch(`/api/v1/faqs/items/${itemId}/status`, payload); +export const setFaqStatus = (itemId: string, studyId: string | null | undefined, payload: { status: "PENDING" | "PROCESSING" | "RESOLVED" }) => + apiPatch(`/api/v1/faqs/items/${itemId}/status`, payload, studyQuery(studyId)); diff --git a/frontend/src/components/FaqCategoryForm.vue b/frontend/src/components/FaqCategoryForm.vue index 4d4f18b8..dcb01735 100644 --- a/frontend/src/components/FaqCategoryForm.vue +++ b/frontend/src/components/FaqCategoryForm.vue @@ -30,13 +30,28 @@ - - + +
+ +