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
+9 -2
View File
@@ -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)
-21
View File
@@ -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,
+17 -4
View File
@@ -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:
-4
View File
@@ -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()
+2 -2
View File
@@ -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()
-1
View File
@@ -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(
+3 -5
View File
@@ -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