From 7c9befc3c9a5861e22016d19b9f564e90792c8dc Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Fri, 26 Dec 2025 14:07:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E2=80=9C=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E6=A8=A1=E5=9D=97=E2=80=9C--=E5=9F=BA=E6=9C=AC?= =?UTF-8?q?=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/faq_categories.py | 56 +- backend/app/api/v1/faqs.py | 309 ++++++++++- backend/app/crud/faq_category.py | 14 + backend/app/crud/faq_item.py | 43 +- backend/app/crud/faq_reply.py | 78 +++ backend/app/db/base.py | 1 + backend/app/models/faq_item.py | 5 + backend/app/models/faq_reply.py | 23 + backend/app/schemas/faq.py | 46 +- database/init.sql | 100 +++- .../migrations/20250120_add_faq_replies.sql | 33 ++ .../20250121_add_faq_reply_soft_delete.sql | 2 + .../20250122_add_faq_best_reply.sql | 15 + .../migrations/20250123_add_faq_status.sql | 4 + .../20250124_add_faq_resolved_by_confirm.sql | 14 + frontend/src/api/faqs.ts | 44 +- frontend/src/components/FaqCategoryForm.vue | 16 +- frontend/src/components/FaqCategoryPanel.vue | 60 ++- frontend/src/components/FaqItemForm.vue | 17 +- frontend/src/components/FaqList.vue | 70 ++- frontend/src/utils/permission.ts | 4 + frontend/src/views/Faq.vue | 22 +- frontend/src/views/FaqDetail.vue | 479 +++++++++++++++++- 23 files changed, 1345 insertions(+), 110 deletions(-) create mode 100644 backend/app/crud/faq_reply.py create mode 100644 backend/app/models/faq_reply.py create mode 100644 database/migrations/20250120_add_faq_replies.sql create mode 100644 database/migrations/20250121_add_faq_reply_soft_delete.sql create mode 100644 database/migrations/20250122_add_faq_best_reply.sql create mode 100644 database/migrations/20250123_add_faq_status.sql create mode 100644 database/migrations/20250124_add_faq_resolved_by_confirm.sql diff --git a/backend/app/api/v1/faq_categories.py b/backend/app/api/v1/faq_categories.py index 349285c0..c10c65d8 100644 --- a/backend/app/api/v1/faq_categories.py +++ b/backend/app/api/v1/faq_categories.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session from app.crud import audit as audit_crud from app.crud import faq_category as category_crud +from app.crud import faq_item as item_crud from app.crud import member as member_crud from app.schemas.common import PaginatedResponse from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate @@ -35,6 +36,9 @@ async def create_category( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> CategoryRead: + 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="分类名称已存在") member_role = None if payload.study_id: member = await member_crud.get_member(db, payload.study_id, current_user.id) @@ -93,15 +97,24 @@ async def update_category( if not category: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") member_role = None - if category.study_id: - from app.crud import member as member_crud - member = await member_crud.get_member(db, category.study_id, current_user.id) + update_data = payload.model_dump(exclude_unset=True) + target_study_id = update_data.get("study_id", category.study_id) + target_name = update_data.get("name", category.name) + if "study_id" in update_data and update_data["study_id"] != category.study_id and current_user.role != "ADMIN": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can change category scope") + 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 target_study_id: + member = await member_crud.get_member(db, target_study_id, current_user.id) member_role = member.role_in_study if member else None - _check_permission_for_scope(category.study_id, current_user, member_role) + if not member and current_user.role != "ADMIN": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") + _check_permission_for_scope(target_study_id, current_user, member_role) updated = await category_crud.update_category(db, category, payload) await audit_crud.log_action( db, - study_id=category.study_id, + study_id=updated.study_id, entity_type="faq_category", entity_id=category_id, action="UPDATE_FAQ_CATEGORY", @@ -110,3 +123,36 @@ async def update_category( operator_role=current_user.role, ) return CategoryRead.model_validate(updated) + + +@router.delete( + "/{category_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="删除 FAQ 分类", + description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。", +) +async def delete_category( + category_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + category = await category_crud.get_category(db, category_id) + if not category: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") + if current_user.role != "ADMIN": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can delete FAQ category") + item_count = await item_crud.count_items_by_category(db, category_id) + if item_count > 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除") + await db.delete(category) + await db.commit() + await audit_crud.log_action( + db, + study_id=category.study_id, + entity_type="faq_category", + entity_id=category_id, + action="DELETE_FAQ_CATEGORY", + detail=f"FAQ category {category_id} deleted", + operator_id=current_user.id, + operator_role=current_user.role, + ) diff --git a/backend/app/api/v1/faqs.py b/backend/app/api/v1/faqs.py index de2922d0..15d2eb3f 100644 --- a/backend/app/api/v1/faqs.py +++ b/backend/app/api/v1/faqs.py @@ -7,9 +7,19 @@ from app.core.deps import get_current_user, get_db_session from app.crud import audit as audit_crud from app.crud import faq_category as category_crud from app.crud import faq_item as faq_crud +from app.crud import faq_reply as reply_crud from app.crud import member as member_crud from app.schemas.common import PaginatedResponse -from app.schemas.faq import FaqCreate, FaqRead, FaqUpdate +from app.schemas.faq import ( + FaqBestReplyUpdate, + FaqCreate, + FaqRead, + FaqReplyCreate, + FaqReplyQuote, + FaqReplyRead, + FaqStatusUpdate, + FaqUpdate, +) from app.utils.pagination import paginate router = APIRouter() @@ -24,6 +34,15 @@ def _check_write_permission(study_id: uuid.UUID | None, current_user, member_rol raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") +def _check_create_permission(study_id: uuid.UUID | None, current_user, is_member: bool): + if study_id is None: + if current_user.role != "ADMIN": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can manage global FAQ") + else: + if current_user.role != "ADMIN" and not is_member: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") + + @router.post( "/", response_model=FaqRead, @@ -42,14 +61,25 @@ async def create_faq( if cat.study_id != payload.study_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Category scope mismatch") member_role = None + is_member = False if payload.study_id: member = await member_crud.get_member(db, payload.study_id, current_user.id) member_role = member.role_in_study if member else None - _check_write_permission(payload.study_id, current_user, member_role) + is_member = member is not None + _check_create_permission(payload.study_id, current_user, is_member) try: item = await faq_crud.create_item(db, payload, created_by=current_user.id) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if payload.answer: + await reply_crud.create_reply( + db, + faq_id=item.id, + study_id=item.study_id, + created_by=current_user.id, + reply_in=FaqReplyCreate(content=payload.answer), + ) + await faq_crud.set_status(db, item.id, "PROCESSING") await audit_crud.log_action( db, study_id=payload.study_id, @@ -73,7 +103,7 @@ async def list_faqs( study_id: uuid.UUID | None = None, category_id: uuid.UUID | None = None, keyword: str | None = None, - is_active: bool | None = True, + is_active: bool | None = None, study_scope: str | None = "project", db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), @@ -167,11 +197,12 @@ 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 not found") - member_role = None - if item.study_id: - member = await member_crud.get_member(db, item.study_id, current_user.id) - member_role = member.role_in_study if member else None - _check_write_permission(item.study_id, current_user, member_role) + if item.created_by != current_user.id: + member_role = None + if item.study_id: + member = await member_crud.get_member(db, item.study_id, current_user.id) + member_role = member.role_in_study if member else None + _check_write_permission(item.study_id, current_user, member_role) old_active = item.is_active updated = await faq_crud.update_item(db, item, payload) action = "UPDATE_FAQ_ITEM" @@ -190,3 +221,265 @@ async def update_faq( operator_role=current_user.role, ) return FaqRead.model_validate(updated) + + +@router.patch( + "/{item_id}/status", + response_model=FaqRead, + summary="更新 FAQ 状态", + description="提问者或项目 PM/ADMIN 可确认已解决。", +) +async def update_faq_status( + item_id: uuid.UUID, + payload: FaqStatusUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FaqRead: + item = await faq_crud.get_item(db, item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") + if payload.status != "RESOLVED": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only RESOLVED is allowed") + if item.created_by != current_user.id and current_user.role != "ADMIN": + member_role = None + if item.study_id: + member = await member_crud.get_member(db, item.study_id, current_user.id) + member_role = member.role_in_study if member else None + if member_role != "PM": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True) + updated = await faq_crud.get_item(db, item_id) + return FaqRead.model_validate(updated) + + +@router.patch( + "/{item_id}/best-reply", + response_model=FaqRead, + summary="设置最佳回复", + description="项目成员可设置最佳回复,全局仅 ADMIN。", +) +async def set_best_reply( + item_id: uuid.UUID, + payload: FaqBestReplyUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FaqRead: + item = await faq_crud.get_item(db, item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") + is_member = False + if item.study_id: + member = await member_crud.get_member(db, item.study_id, current_user.id) + is_member = member is not None + _check_create_permission(item.study_id, current_user, is_member) + if payload.best_reply_id: + reply = await reply_crud.get_reply(db, payload.best_reply_id) + if not reply or reply.faq_id != item.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid reply") + if reply.is_deleted: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reply deleted") + await faq_crud.set_best_reply(db, item.id, reply.id) + await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=item.resolved_by_confirm) + else: + await faq_crud.set_best_reply(db, item.id, None) + if not item.resolved_by_confirm: + remaining = await reply_crud.count_active_replies(db, item.id) + await faq_crud.set_status( + db, + item.id, + "PROCESSING" if remaining > 0 else "PENDING", + resolved_by_confirm=False, + ) + updated = await faq_crud.get_item(db, item_id) + return FaqRead.model_validate(updated) + + +@router.get( + "/{item_id}/replies", + response_model=PaginatedResponse[FaqReplyRead], + summary="FAQ 回复列表", + description="获取 FAQ 的回复列表。", +) +async def list_replies( + item_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> list[FaqReplyRead]: + item = await faq_crud.get_item(db, item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") + if item.study_id and current_user.role != "ADMIN": + member = await member_crud.get_member(db, item.study_id, current_user.id) + if not member: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member") + replies = await reply_crud.list_replies(db, item_id) + reply_map = {r.id: r for r in replies} + result: list[FaqReplyRead] = [] + for r in replies: + data = FaqReplyRead.model_validate(r) + if r.quote_reply_id and r.quote_reply_id in reply_map: + quote = reply_map[r.quote_reply_id] + if quote.is_deleted: + data.quote = FaqReplyQuote( + id=quote.id, + content="回复已删除", + created_by=quote.created_by, + created_at=quote.created_at, + ) + else: + data.quote = FaqReplyQuote.model_validate(quote) + result.append(data) + return paginate(result, total=len(result)) + + +@router.post( + "/{item_id}/replies", + response_model=FaqReplyRead, + status_code=status.HTTP_201_CREATED, + summary="创建 FAQ 回复", + description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。", +) +async def create_reply( + item_id: uuid.UUID, + payload: FaqReplyCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FaqReplyRead: + item = await faq_crud.get_item(db, item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") + if not payload.content.strip(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reply content is required") + is_member = False + if item.study_id: + member = await member_crud.get_member(db, item.study_id, current_user.id) + is_member = member is not None + _check_create_permission(item.study_id, current_user, is_member) + quote = None + if payload.quote_reply_id: + quote = await reply_crud.get_reply(db, payload.quote_reply_id) + if not quote or quote.faq_id != item.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid quote reply") + reply = await reply_crud.create_reply( + db, + faq_id=item.id, + study_id=item.study_id, + created_by=current_user.id, + reply_in=payload, + ) + if item.status != "RESOLVED": + await faq_crud.set_status(db, item.id, "PROCESSING") + await faq_crud.touch_item(db, item.id) + await audit_crud.log_action( + db, + study_id=item.study_id, + entity_type="faq_reply", + entity_id=reply.id, + action="CREATE_FAQ_REPLY", + detail="FAQ replied", + operator_id=current_user.id, + operator_role=current_user.role, + ) + data = FaqReplyRead.model_validate(reply) + if quote: + if quote.is_deleted: + data.quote = FaqReplyQuote( + id=quote.id, + content="回复已删除", + created_by=quote.created_by, + created_at=quote.created_at, + ) + else: + data.quote = FaqReplyQuote.model_validate(quote) + return data + + +@router.delete( + "/{item_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="删除 FAQ", + description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。", +) +async def delete_faq( + item_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + item = await faq_crud.get_item(db, item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") + if item.created_by != current_user.id: + member_role = None + if item.study_id: + member = await member_crud.get_member(db, item.study_id, current_user.id) + member_role = member.role_in_study if member else None + _check_write_permission(item.study_id, current_user, member_role) + await reply_crud.delete_replies_by_faq_id(db, item.id) + await db.delete(item) + await db.commit() + await audit_crud.log_action( + db, + study_id=item.study_id, + entity_type="faq_item", + entity_id=item_id, + action="DELETE_FAQ_ITEM", + detail="FAQ deleted", + operator_id=current_user.id, + operator_role=current_user.role, + ) + + +@router.delete( + "/{item_id}/replies/{reply_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="删除 FAQ 回复", + description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。", +) +async def delete_reply( + item_id: uuid.UUID, + reply_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + item = await faq_crud.get_item(db, item_id) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") + reply = await reply_crud.get_reply(db, reply_id) + if not reply or reply.faq_id != item.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reply not found") + if reply.created_by != current_user.id and current_user.role != "ADMIN": + member_role = None + if item.study_id: + member = await member_crud.get_member(db, item.study_id, current_user.id) + member_role = member.role_in_study if member else None + if member_role != "PM": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + if item.best_reply_id == reply.id: + await faq_crud.set_best_reply(db, item.id, None) + ref_count = await reply_crud.count_quote_references(db, reply.id) + if ref_count > 0: + await reply_crud.soft_delete_reply(db, reply) + else: + await reply_crud.delete_reply(db, reply) + if not item.resolved_by_confirm: + if item.best_reply_id and item.best_reply_id != reply.id: + await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=False) + else: + remaining = await reply_crud.count_active_replies(db, item.id) + await faq_crud.set_status( + db, + item.id, + "PROCESSING" if remaining > 0 else "PENDING", + resolved_by_confirm=False, + ) + await faq_crud.touch_item(db, item.id) + await audit_crud.log_action( + db, + study_id=item.study_id, + entity_type="faq_reply", + entity_id=reply_id, + action="DELETE_FAQ_REPLY", + detail="FAQ reply deleted", + operator_id=current_user.id, + operator_role=current_user.role, + ) diff --git a/backend/app/crud/faq_category.py b/backend/app/crud/faq_category.py index 7390e70f..5b90274a 100644 --- a/backend/app/crud/faq_category.py +++ b/backend/app/crud/faq_category.py @@ -45,6 +45,20 @@ async def get_category(db: AsyncSession, category_id: uuid.UUID) -> FaqCategory return result.scalar_one_or_none() +async def get_category_by_name( + db: AsyncSession, + study_id: uuid.UUID | None, + name: str, +) -> FaqCategory | None: + stmt = select(FaqCategory).where(FaqCategory.name == name) + 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 d49276d9..c40120ba 100644 --- a/backend/app/crud/faq_item.py +++ b/backend/app/crud/faq_item.py @@ -1,7 +1,7 @@ import uuid from typing import Sequence -from sqlalchemy import or_, select, update as sa_update +from sqlalchemy import func, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.models.faq_category import FaqCategory @@ -23,8 +23,8 @@ async def create_item(db: AsyncSession, item_in: FaqCreate, *, created_by: uuid. study_id=item_in.study_id, category_id=item_in.category_id, question=item_in.question, - answer=item_in.answer, - keywords=item_in.keywords, + answer=item_in.answer or "", + status="PENDING", version=1, is_active=True, created_by=created_by, @@ -56,7 +56,7 @@ async def list_items( elif study_scope == "global": stmt = stmt.where(FaqItem.study_id.is_(None)) else: # project (default) - stmt = stmt.where((FaqItem.study_id == study_id) | (FaqItem.study_id.is_(None))) + stmt = stmt.where(FaqItem.study_id == study_id) if category_id: stmt = stmt.where(FaqItem.category_id == category_id) @@ -88,3 +88,38 @@ async def update_item(db: AsyncSession, item: FaqItem, item_in: FaqUpdate) -> Fa await db.commit() await db.refresh(item) return item + + +async def touch_item(db: AsyncSession, item_id: uuid.UUID) -> None: + await db.execute( + sa_update(FaqItem) + .where(FaqItem.id == item_id) + .values(updated_at=func.now()) + ) + await db.commit() + + +async def set_best_reply(db: AsyncSession, item_id: uuid.UUID, reply_id: uuid.UUID | None) -> None: + await db.execute( + sa_update(FaqItem) + .where(FaqItem.id == item_id) + .values(best_reply_id=reply_id, updated_at=func.now()) + ) + await db.commit() + + +async def set_status(db: AsyncSession, item_id: uuid.UUID, status: str, resolved_by_confirm: bool | None = None) -> None: + values: dict[str, object] = {"status": status, "updated_at": func.now()} + if resolved_by_confirm is not None: + values["resolved_by_confirm"] = resolved_by_confirm + await db.execute( + sa_update(FaqItem) + .where(FaqItem.id == item_id) + .values(**values) + ) + await db.commit() + + +async def count_items_by_category(db: AsyncSession, category_id: uuid.UUID) -> int: + result = await db.execute(select(func.count()).select_from(FaqItem).where(FaqItem.category_id == category_id)) + return int(result.scalar_one()) diff --git a/backend/app/crud/faq_reply.py b/backend/app/crud/faq_reply.py new file mode 100644 index 00000000..c3f5e06b --- /dev/null +++ b/backend/app/crud/faq_reply.py @@ -0,0 +1,78 @@ +import uuid +from typing import Sequence + +from sqlalchemy import delete as sa_delete, func, select, update as sa_update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.faq_reply import FaqReply +from app.schemas.faq import FaqReplyCreate + + +async def create_reply( + db: AsyncSession, + *, + faq_id: uuid.UUID, + study_id: uuid.UUID | None, + created_by: uuid.UUID, + reply_in: FaqReplyCreate, +) -> FaqReply: + reply = FaqReply( + faq_id=faq_id, + study_id=study_id, + content=reply_in.content, + created_by=created_by, + quote_reply_id=reply_in.quote_reply_id, + ) + db.add(reply) + await db.commit() + await db.refresh(reply) + return reply + + +async def list_replies(db: AsyncSession, faq_id: uuid.UUID) -> Sequence[FaqReply]: + result = await db.execute( + select(FaqReply).where(FaqReply.faq_id == faq_id).order_by(FaqReply.created_at) + ) + return result.scalars().all() + + +async def get_reply(db: AsyncSession, reply_id: uuid.UUID) -> FaqReply | None: + result = await db.execute(select(FaqReply).where(FaqReply.id == reply_id)) + return result.scalar_one_or_none() + + +async def delete_reply(db: AsyncSession, reply: FaqReply) -> None: + await db.delete(reply) + await db.commit() + + +async def soft_delete_reply(db: AsyncSession, reply: FaqReply) -> None: + reply.is_deleted = True + reply.content = "" + await db.commit() + + +async def count_quote_references(db: AsyncSession, reply_id: uuid.UUID) -> int: + result = await db.execute( + select(func.count()).select_from(FaqReply).where(FaqReply.quote_reply_id == reply_id) + ) + return int(result.scalar_one()) + + +async def count_active_replies(db: AsyncSession, faq_id: uuid.UUID) -> int: + result = await db.execute( + select(func.count()) + .select_from(FaqReply) + .where(FaqReply.faq_id == faq_id, FaqReply.is_deleted.is_(False)) + ) + return int(result.scalar_one()) + + +async def delete_replies_by_faq_id(db: AsyncSession, faq_id: uuid.UUID) -> None: + await db.execute( + sa_update(FaqReply) + .where(FaqReply.faq_id == faq_id) + .values(quote_reply_id=None) + ) + await db.execute(sa_delete(FaqReply).where(FaqReply.faq_id == faq_id)) + await db.commit() diff --git a/backend/app/db/base.py b/backend/app/db/base.py index faa7c2fe..1e8c11e8 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -22,3 +22,4 @@ from app.models.imp_transaction import ImpTransaction # noqa: F401 from app.models.finance import FinanceItem # noqa: F401 from app.models.faq_category import FaqCategory # noqa: F401 from app.models.faq_item import FaqItem # noqa: F401 +from app.models.faq_reply import FaqReply # noqa: F401 diff --git a/backend/app/models/faq_item.py b/backend/app/models/faq_item.py index b9d1f42e..e4e4b9c3 100644 --- a/backend/app/models/faq_item.py +++ b/backend/app/models/faq_item.py @@ -14,6 +14,11 @@ class FaqItem(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) study_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True, index=True) category_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("faq_categories.id"), index=True, nullable=False) + best_reply_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("faq_replies.id", ondelete="SET NULL"), nullable=True + ) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="PENDING") + resolved_by_confirm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) question: Mapped[str] = mapped_column(Text, nullable=False) answer: Mapped[str] = mapped_column(Text, nullable=False) keywords: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/models/faq_reply.py b/backend/app/models/faq_reply.py new file mode 100644 index 00000000..672d1dc1 --- /dev/null +++ b/backend/app/models/faq_reply.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class FaqReply(Base): + __tablename__ = "faq_replies" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + faq_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("faq_items.id"), index=True, nullable=False) + study_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + 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()) + quote_reply_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("faq_replies.id"), nullable=True + ) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") diff --git a/backend/app/schemas/faq.py b/backend/app/schemas/faq.py index 6b284695..4d5624aa 100644 --- a/backend/app/schemas/faq.py +++ b/backend/app/schemas/faq.py @@ -1,6 +1,6 @@ import uuid from datetime import datetime -from typing import Optional +from typing import Literal, Optional from pydantic import BaseModel, ConfigDict @@ -14,6 +14,7 @@ class CategoryCreate(BaseModel): class CategoryUpdate(BaseModel): + study_id: Optional[uuid.UUID] = None name: Optional[str] = None description: Optional[str] = None sort_order: Optional[int] = None @@ -37,21 +38,58 @@ class FaqCreate(BaseModel): study_id: Optional[uuid.UUID] = None category_id: uuid.UUID question: str - answer: str - keywords: Optional[str] = None + answer: Optional[str] = None class FaqUpdate(BaseModel): question: Optional[str] = None answer: Optional[str] = None - keywords: Optional[str] = None is_active: Optional[bool] = None +class FaqStatusUpdate(BaseModel): + status: Literal["PENDING", "PROCESSING", "RESOLVED"] + + +class FaqBestReplyUpdate(BaseModel): + best_reply_id: Optional[uuid.UUID] = None + + +class FaqReplyCreate(BaseModel): + content: str + quote_reply_id: Optional[uuid.UUID] = None + + +class FaqReplyQuote(BaseModel): + id: uuid.UUID + content: str + created_by: uuid.UUID + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class FaqReplyRead(BaseModel): + id: uuid.UUID + faq_id: uuid.UUID + study_id: Optional[uuid.UUID] + content: str + created_by: uuid.UUID + created_at: datetime + quote_reply_id: Optional[uuid.UUID] = None + quote: Optional[FaqReplyQuote] = None + is_deleted: bool = False + + model_config = ConfigDict(from_attributes=True) + + class FaqRead(BaseModel): id: uuid.UUID study_id: Optional[uuid.UUID] category_id: uuid.UUID + best_reply_id: Optional[uuid.UUID] = None + status: Literal["PENDING", "PROCESSING", "RESOLVED"] = "PENDING" + resolved_by_confirm: bool = False question: str answer: str keywords: Optional[str] diff --git a/database/init.sql b/database/init.sql index d4f8cfd7..e461cb75 100644 --- a/database/init.sql +++ b/database/init.sql @@ -161,6 +161,9 @@ CREATE TABLE public.faq_items ( id uuid NOT NULL, study_id uuid, category_id uuid NOT NULL, + best_reply_id uuid, + status character varying(20) DEFAULT 'PENDING'::character varying NOT NULL, + resolved_by_confirm boolean DEFAULT false NOT NULL, question text NOT NULL, answer text NOT NULL, keywords character varying(255), @@ -174,6 +177,24 @@ CREATE TABLE public.faq_items ( ALTER TABLE public.faq_items OWNER TO ctms_user; +-- +-- Name: faq_replies; Type: TABLE; Schema: public; Owner: ctms_user +-- + +CREATE TABLE public.faq_replies ( + id uuid NOT NULL, + faq_id uuid NOT NULL, + study_id uuid, + content text NOT NULL, + created_by uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + quote_reply_id uuid, + is_deleted boolean DEFAULT false NOT NULL +); + + +ALTER TABLE public.faq_replies OWNER TO ctms_user; + -- -- Name: finance_items; Type: TABLE; Schema: public; Owner: ctms_user -- @@ -541,8 +562,16 @@ eeeeeee1-eeee-eeee-eeee-eeeeeeeeeee1 44444444-4444-4444-4444-444444444444 访视 -- Data for Name: faq_items; Type: TABLE DATA; Schema: public; Owner: ctms_user -- -COPY public.faq_items (id, study_id, category_id, question, answer, keywords, version, is_active, created_by, created_at, updated_at) FROM stdin; -fffffff1-ffff-ffff-ffff-fffffffffff1 44444444-4444-4444-4444-444444444444 eeeeeee1-eeee-eeee-eeee-eeeeeeeeeee1 V1 访视需要携带什么? 携带受试者知情同意书、病历和药物记录本。 访视,携带物品 1 t 22222222-2222-2222-2222-222222222222 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 +COPY public.faq_items (id, study_id, category_id, best_reply_id, status, resolved_by_confirm, question, answer, keywords, version, is_active, created_by, created_at, updated_at) FROM stdin; +fffffff1-ffff-ffff-ffff-fffffffffff1 44444444-4444-4444-4444-444444444444 eeeeeee1-eeee-eeee-eeee-eeeeeeeeeee1 \N PENDING f V1 访视需要携带什么? 携带受试者知情同意书、病历和药物记录本。 访视,携带物品 1 t 22222222-2222-2222-2222-222222222222 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 +\. + + +-- +-- Data for Name: faq_replies; Type: TABLE DATA; Schema: public; Owner: ctms_user +-- + +COPY public.faq_replies (id, faq_id, study_id, content, created_by, created_at, quote_reply_id, is_deleted) FROM stdin; \. @@ -739,6 +768,14 @@ ALTER TABLE ONLY public.faq_items ADD CONSTRAINT faq_items_pkey PRIMARY KEY (id); +-- +-- Name: faq_replies faq_replies_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user +-- + +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_pkey PRIMARY KEY (id); + + -- -- Name: finance_items finance_items_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user -- @@ -974,6 +1011,12 @@ CREATE INDEX ix_faq_categories_study_id ON public.faq_categories USING btree (st CREATE INDEX ix_faq_items_category_id ON public.faq_items USING btree (category_id); +-- +-- Name: ix_faq_items_best_reply_id; Type: INDEX; Schema: public; Owner: ctms_user +-- + +CREATE INDEX ix_faq_items_best_reply_id ON public.faq_items USING btree (best_reply_id); + -- -- Name: ix_faq_items_study_id; Type: INDEX; Schema: public; Owner: ctms_user @@ -982,6 +1025,20 @@ CREATE INDEX ix_faq_items_category_id ON public.faq_items USING btree (category_ CREATE INDEX ix_faq_items_study_id ON public.faq_items USING btree (study_id); +-- +-- Name: ix_faq_replies_faq_id; Type: INDEX; Schema: public; Owner: ctms_user +-- + +CREATE INDEX ix_faq_replies_faq_id ON public.faq_replies USING btree (faq_id); + + +-- +-- Name: ix_faq_replies_study_id; Type: INDEX; Schema: public; Owner: ctms_user +-- + +CREATE INDEX ix_faq_replies_study_id ON public.faq_replies USING btree (study_id); + + -- -- Name: ix_finance_items_site_id; Type: INDEX; Schema: public; Owner: ctms_user -- @@ -1304,6 +1361,13 @@ ALTER TABLE ONLY public.faq_categories ALTER TABLE ONLY public.faq_items ADD CONSTRAINT faq_items_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.faq_categories(id); +-- +-- Name: faq_items faq_items_best_reply_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user +-- + +ALTER TABLE ONLY public.faq_items + ADD CONSTRAINT faq_items_best_reply_id_fkey FOREIGN KEY (best_reply_id) REFERENCES public.faq_replies(id) ON DELETE SET NULL; + -- -- Name: faq_items faq_items_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user @@ -1321,6 +1385,38 @@ ALTER TABLE ONLY public.faq_items ADD CONSTRAINT faq_items_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); +-- +-- Name: faq_replies faq_replies_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user +-- + +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); + + +-- +-- Name: faq_replies faq_replies_faq_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user +-- + +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_faq_id_fkey FOREIGN KEY (faq_id) REFERENCES public.faq_items(id); + + +-- +-- Name: faq_replies faq_replies_quote_reply_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user +-- + +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_quote_reply_id_fkey FOREIGN KEY (quote_reply_id) REFERENCES public.faq_replies(id); + + +-- +-- Name: faq_replies faq_replies_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user +-- + +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); + + -- -- Name: finance_items finance_items_approver_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user -- diff --git a/database/migrations/20250120_add_faq_replies.sql b/database/migrations/20250120_add_faq_replies.sql new file mode 100644 index 00000000..e3a4cd6a --- /dev/null +++ b/database/migrations/20250120_add_faq_replies.sql @@ -0,0 +1,33 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE public.faq_replies ( + id uuid NOT NULL, + faq_id uuid NOT NULL, + study_id uuid, + content text NOT NULL, + created_by uuid NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + quote_reply_id uuid +); + +ALTER TABLE public.faq_replies OWNER TO ctms_user; + +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_pkey PRIMARY KEY (id); + +CREATE INDEX ix_faq_replies_faq_id ON public.faq_replies USING btree (faq_id); +CREATE INDEX ix_faq_replies_study_id ON public.faq_replies USING btree (study_id); + +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_faq_id_fkey FOREIGN KEY (faq_id) REFERENCES public.faq_items(id); +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_quote_reply_id_fkey FOREIGN KEY (quote_reply_id) REFERENCES public.faq_replies(id); +ALTER TABLE ONLY public.faq_replies + ADD CONSTRAINT faq_replies_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); + +INSERT INTO public.faq_replies (id, faq_id, study_id, content, created_by, created_at) +SELECT gen_random_uuid(), id, study_id, answer, created_by, updated_at +FROM public.faq_items +WHERE answer IS NOT NULL AND btrim(answer) <> ''; diff --git a/database/migrations/20250121_add_faq_reply_soft_delete.sql b/database/migrations/20250121_add_faq_reply_soft_delete.sql new file mode 100644 index 00000000..f8e7f50a --- /dev/null +++ b/database/migrations/20250121_add_faq_reply_soft_delete.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.faq_replies + ADD COLUMN IF NOT EXISTS is_deleted boolean DEFAULT false NOT NULL; diff --git a/database/migrations/20250122_add_faq_best_reply.sql b/database/migrations/20250122_add_faq_best_reply.sql new file mode 100644 index 00000000..c3fbe959 --- /dev/null +++ b/database/migrations/20250122_add_faq_best_reply.sql @@ -0,0 +1,15 @@ +ALTER TABLE public.faq_items + ADD COLUMN IF NOT EXISTS best_reply_id uuid; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'faq_items_best_reply_id_fkey' + ) THEN + ALTER TABLE ONLY public.faq_items + ADD CONSTRAINT faq_items_best_reply_id_fkey + FOREIGN KEY (best_reply_id) REFERENCES public.faq_replies(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS ix_faq_items_best_reply_id ON public.faq_items USING btree (best_reply_id); diff --git a/database/migrations/20250123_add_faq_status.sql b/database/migrations/20250123_add_faq_status.sql new file mode 100644 index 00000000..1b7c9da2 --- /dev/null +++ b/database/migrations/20250123_add_faq_status.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.faq_items + ADD COLUMN IF NOT EXISTS status character varying(20) DEFAULT 'PENDING' NOT NULL; + +UPDATE public.faq_items SET status = 'PENDING' WHERE status IS NULL; diff --git a/database/migrations/20250124_add_faq_resolved_by_confirm.sql b/database/migrations/20250124_add_faq_resolved_by_confirm.sql new file mode 100644 index 00000000..496a733e --- /dev/null +++ b/database/migrations/20250124_add_faq_resolved_by_confirm.sql @@ -0,0 +1,14 @@ +ALTER TABLE public.faq_items + ADD COLUMN IF NOT EXISTS resolved_by_confirm boolean DEFAULT false NOT NULL; + +UPDATE public.faq_items +SET status = CASE + WHEN best_reply_id IS NOT NULL THEN 'RESOLVED' + WHEN EXISTS ( + SELECT 1 FROM public.faq_replies r + WHERE r.faq_id = faq_items.id AND r.is_deleted = false + ) THEN 'PROCESSING' + ELSE 'PENDING' +END, +resolved_by_confirm = false +WHERE status = 'RESOLVED' AND best_reply_id IS NULL; diff --git a/frontend/src/api/faqs.ts b/frontend/src/api/faqs.ts index ee4cb310..2b8ef8fa 100644 --- a/frontend/src/api/faqs.ts +++ b/frontend/src/api/faqs.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPatch, apiPost } from "./axios"; +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; import type { AxiosResponse } from "axios"; import type { ApiListResponse } from "../types/api"; @@ -17,6 +17,8 @@ export interface FaqItem { id: string; study_id?: string | null; category_id: string; + best_reply_id?: string | null; + status?: "PENDING" | "PROCESSING" | "RESOLVED"; question: string; answer: string; keywords?: string | null; @@ -27,6 +29,25 @@ export interface FaqItem { updated_at: string; } +export interface FaqReplyQuote { + id: string; + content: string; + created_by: string; + created_at: string; +} + +export interface FaqReply { + id: string; + faq_id: string; + study_id?: string | null; + content: string; + created_by: string; + created_at: string; + quote_reply_id?: string | null; + quote?: FaqReplyQuote | null; + is_deleted?: boolean; +} + export const fetchFaqCategories = (params?: Record): Promise>> => apiGet("/api/v1/faqs/categories", { params }); @@ -36,6 +57,9 @@ export const createFaqCategory = (payload: Record) => export const updateFaqCategory = (categoryId: string, payload: Record) => apiPatch(`/api/v1/faqs/categories/${categoryId}`, payload); +export const deleteFaqCategory = (categoryId: string) => + apiDelete(`/api/v1/faqs/categories/${categoryId}`); + export const fetchFaqItems = (params?: Record): Promise>> => apiGet("/api/v1/faqs/items", { params }); @@ -45,3 +69,21 @@ export const createFaqItem = (payload: Record) => apiPost( export const updateFaqItem = (itemId: string, payload: Record) => apiPatch(`/api/v1/faqs/items/${itemId}`, payload); + +export const deleteFaqItem = (itemId: string) => + apiDelete(`/api/v1/faqs/items/${itemId}`); + +export const fetchFaqReplies = (itemId: string) => + apiGet(`/api/v1/faqs/items/${itemId}/replies`); + +export const createFaqReply = (itemId: string, payload: { content: string; quote_reply_id?: string | null }) => + apiPost(`/api/v1/faqs/items/${itemId}/replies`, payload); + +export const deleteFaqReply = (itemId: string, replyId: string) => + apiDelete(`/api/v1/faqs/items/${itemId}/replies/${replyId}`); + +export const setFaqBestReply = (itemId: string, payload: { best_reply_id: string | null }) => + apiPatch(`/api/v1/faqs/items/${itemId}/best-reply`, payload); + +export const setFaqStatus = (itemId: string, payload: { status: "PENDING" | "PROCESSING" | "RESOLVED" }) => + apiPatch(`/api/v1/faqs/items/${itemId}/status`, payload); diff --git a/frontend/src/components/FaqCategoryForm.vue b/frontend/src/components/FaqCategoryForm.vue index cf10bb7a..0e18eb05 100644 --- a/frontend/src/components/FaqCategoryForm.vue +++ b/frontend/src/components/FaqCategoryForm.vue @@ -49,7 +49,7 @@ const rules: FormRules = { const isEdit = computed(() => !!props.category); const reset = () => { - form.study_id = props.isAdmin ? "" : study.currentStudy?.id || ""; + form.study_id = study.currentStudy?.id || ""; form.name = ""; form.description = ""; form.sort_order = 0; @@ -72,6 +72,15 @@ watch( { immediate: true } ); +watch( + () => props.modelValue, + (val) => { + if (val && !props.category) { + reset(); + } + } +); + const close = () => emit("update:modelValue", false); const onSubmit = async () => { @@ -80,15 +89,12 @@ const onSubmit = async () => { submitting.value = true; try { const payload: Record = { - study_id: form.study_id || null, + study_id: study.currentStudy?.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 { diff --git a/frontend/src/components/FaqCategoryPanel.vue b/frontend/src/components/FaqCategoryPanel.vue index 2f978b25..dd1a586e 100644 --- a/frontend/src/components/FaqCategoryPanel.vue +++ b/frontend/src/components/FaqCategoryPanel.vue @@ -8,22 +8,18 @@ - + 全部 - {{ c.name }} - 项目 - 全局 - - - 编辑 - - + {{ c.name }} +
+ + 编辑 + + + 删除 + +
@@ -32,8 +28,10 @@ diff --git a/frontend/src/components/FaqItemForm.vue b/frontend/src/components/FaqItemForm.vue index fdb12527..1dec2bc2 100644 --- a/frontend/src/components/FaqItemForm.vue +++ b/frontend/src/components/FaqItemForm.vue @@ -9,12 +9,6 @@ - - - - - - @@ -43,15 +37,12 @@ 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); @@ -60,8 +51,6 @@ const reset = () => { form.study_id = study.currentStudy?.id || ""; form.category_id = ""; form.question = ""; - form.answer = ""; - form.keywords = ""; form.is_active = true; }; @@ -72,8 +61,6 @@ watch( 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(); @@ -90,11 +77,9 @@ const onSubmit = async () => { submitting.value = true; try { const payload: Record = { - study_id: form.study_id || null, + study_id: study.currentStudy?.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) { diff --git a/frontend/src/components/FaqList.vue b/frontend/src/components/FaqList.vue index 4fdb8c55..c40b2e3d 100644 --- a/frontend/src/components/FaqList.vue +++ b/frontend/src/components/FaqList.vue @@ -1,12 +1,17 @@