优化“知识库模块“--基本完善

This commit is contained in:
Cheng Zhou
2025-12-26 14:07:28 +08:00
parent a1a4964cd2
commit 7c9befc3c9
23 changed files with 1345 additions and 110 deletions
+51 -5
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import faq_category as category_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.crud import member as member_crud
from app.schemas.common import PaginatedResponse from app.schemas.common import PaginatedResponse
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
@@ -35,6 +36,9 @@ async def create_category(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> CategoryRead: ) -> 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 member_role = None
if payload.study_id: if payload.study_id:
member = await member_crud.get_member(db, payload.study_id, current_user.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: if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
member_role = None member_role = None
if category.study_id: update_data = payload.model_dump(exclude_unset=True)
from app.crud import member as member_crud target_study_id = update_data.get("study_id", category.study_id)
member = await member_crud.get_member(db, category.study_id, current_user.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 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) updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
study_id=category.study_id, study_id=updated.study_id,
entity_type="faq_category", entity_type="faq_category",
entity_id=category_id, entity_id=category_id,
action="UPDATE_FAQ_CATEGORY", action="UPDATE_FAQ_CATEGORY",
@@ -110,3 +123,36 @@ async def update_category(
operator_role=current_user.role, operator_role=current_user.role,
) )
return CategoryRead.model_validate(updated) 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,
)
+301 -8
View File
@@ -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 audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_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.crud import member as member_crud
from app.schemas.common import PaginatedResponse 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 from app.utils.pagination import paginate
router = APIRouter() 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") 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( @router.post(
"/", "/",
response_model=FaqRead, response_model=FaqRead,
@@ -42,14 +61,25 @@ async def create_faq(
if cat.study_id != payload.study_id: if cat.study_id != payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Category scope mismatch") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Category scope mismatch")
member_role = None member_role = None
is_member = False
if payload.study_id: if payload.study_id:
member = await member_crud.get_member(db, payload.study_id, current_user.id) member = await member_crud.get_member(db, payload.study_id, current_user.id)
member_role = member.role_in_study if member else None 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: try:
item = await faq_crud.create_item(db, payload, created_by=current_user.id) item = await faq_crud.create_item(db, payload, created_by=current_user.id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from 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( await audit_crud.log_action(
db, db,
study_id=payload.study_id, study_id=payload.study_id,
@@ -73,7 +103,7 @@ async def list_faqs(
study_id: uuid.UUID | None = None, study_id: uuid.UUID | None = None,
category_id: uuid.UUID | None = None, category_id: uuid.UUID | None = None,
keyword: str | None = None, keyword: str | None = None,
is_active: bool | None = True, is_active: bool | None = None,
study_scope: str | None = "project", study_scope: str | None = "project",
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
@@ -167,11 +197,12 @@ async def update_faq(
item = await faq_crud.get_item(db, item_id) item = await faq_crud.get_item(db, item_id)
if not item: if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
member_role = None if item.created_by != current_user.id:
if item.study_id: member_role = None
member = await member_crud.get_member(db, item.study_id, current_user.id) if item.study_id:
member_role = member.role_in_study if member else None member = await member_crud.get_member(db, item.study_id, current_user.id)
_check_write_permission(item.study_id, current_user, member_role) 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 old_active = item.is_active
updated = await faq_crud.update_item(db, item, payload) updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM" action = "UPDATE_FAQ_ITEM"
@@ -190,3 +221,265 @@ async def update_faq(
operator_role=current_user.role, operator_role=current_user.role,
) )
return FaqRead.model_validate(updated) 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,
)
+14
View File
@@ -45,6 +45,20 @@ async def get_category(db: AsyncSession, category_id: uuid.UUID) -> FaqCategory
return result.scalar_one_or_none() 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: async def update_category(db: AsyncSession, category: FaqCategory, category_in: CategoryUpdate) -> FaqCategory:
update_data = category_in.model_dump(exclude_unset=True) update_data = category_in.model_dump(exclude_unset=True)
if update_data: if update_data:
+39 -4
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from typing import Sequence 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 sqlalchemy.ext.asyncio import AsyncSession
from app.models.faq_category import FaqCategory 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, study_id=item_in.study_id,
category_id=item_in.category_id, category_id=item_in.category_id,
question=item_in.question, question=item_in.question,
answer=item_in.answer, answer=item_in.answer or "",
keywords=item_in.keywords, status="PENDING",
version=1, version=1,
is_active=True, is_active=True,
created_by=created_by, created_by=created_by,
@@ -56,7 +56,7 @@ async def list_items(
elif study_scope == "global": elif study_scope == "global":
stmt = stmt.where(FaqItem.study_id.is_(None)) stmt = stmt.where(FaqItem.study_id.is_(None))
else: # project (default) 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: if category_id:
stmt = stmt.where(FaqItem.category_id == 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.commit()
await db.refresh(item) await db.refresh(item)
return 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())
+78
View File
@@ -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()
+1
View File
@@ -22,3 +22,4 @@ from app.models.imp_transaction import ImpTransaction # noqa: F401
from app.models.finance import FinanceItem # noqa: F401 from app.models.finance import FinanceItem # noqa: F401
from app.models.faq_category import FaqCategory # noqa: F401 from app.models.faq_category import FaqCategory # noqa: F401
from app.models.faq_item import FaqItem # noqa: F401 from app.models.faq_item import FaqItem # noqa: F401
from app.models.faq_reply import FaqReply # noqa: F401
+5
View File
@@ -14,6 +14,11 @@ class FaqItem(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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) 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) 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) question: Mapped[str] = mapped_column(Text, nullable=False)
answer: 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) keywords: Mapped[str | None] = mapped_column(String(255), nullable=True)
+23
View File
@@ -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")
+42 -4
View File
@@ -1,6 +1,6 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -14,6 +14,7 @@ class CategoryCreate(BaseModel):
class CategoryUpdate(BaseModel): class CategoryUpdate(BaseModel):
study_id: Optional[uuid.UUID] = None
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
@@ -37,21 +38,58 @@ class FaqCreate(BaseModel):
study_id: Optional[uuid.UUID] = None study_id: Optional[uuid.UUID] = None
category_id: uuid.UUID category_id: uuid.UUID
question: str question: str
answer: str answer: Optional[str] = None
keywords: Optional[str] = None
class FaqUpdate(BaseModel): class FaqUpdate(BaseModel):
question: Optional[str] = None question: Optional[str] = None
answer: Optional[str] = None answer: Optional[str] = None
keywords: Optional[str] = None
is_active: Optional[bool] = 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): class FaqRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: Optional[uuid.UUID] study_id: Optional[uuid.UUID]
category_id: 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 question: str
answer: str answer: str
keywords: Optional[str] keywords: Optional[str]
+98 -2
View File
@@ -161,6 +161,9 @@ CREATE TABLE public.faq_items (
id uuid NOT NULL, id uuid NOT NULL,
study_id uuid, study_id uuid,
category_id uuid NOT NULL, 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, question text NOT NULL,
answer text NOT NULL, answer text NOT NULL,
keywords character varying(255), keywords character varying(255),
@@ -174,6 +177,24 @@ CREATE TABLE public.faq_items (
ALTER TABLE public.faq_items OWNER TO ctms_user; 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 -- 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 -- 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; 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 V1 访 访, 1 t 22222222-2222-2222-2222-222222222222 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 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); 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 -- 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); 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 -- 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); 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 -- 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 ALTER TABLE ONLY public.faq_items
ADD CONSTRAINT faq_items_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.faq_categories(id); 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 -- 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); 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 -- Name: finance_items finance_items_approver_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user
-- --
@@ -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) <> '';
@@ -0,0 +1,2 @@
ALTER TABLE public.faq_replies
ADD COLUMN IF NOT EXISTS is_deleted boolean DEFAULT false NOT NULL;
@@ -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);
@@ -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;
@@ -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;
+43 -1
View File
@@ -1,4 +1,4 @@
import { apiGet, apiPatch, apiPost } from "./axios"; import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import type { ApiListResponse } from "../types/api"; import type { ApiListResponse } from "../types/api";
@@ -17,6 +17,8 @@ export interface FaqItem {
id: string; id: string;
study_id?: string | null; study_id?: string | null;
category_id: string; category_id: string;
best_reply_id?: string | null;
status?: "PENDING" | "PROCESSING" | "RESOLVED";
question: string; question: string;
answer: string; answer: string;
keywords?: string | null; keywords?: string | null;
@@ -27,6 +29,25 @@ export interface FaqItem {
updated_at: string; 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<string, any>): Promise<AxiosResponse<ApiListResponse<FaqCategory>>> => export const fetchFaqCategories = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqCategory>>> =>
apiGet("/api/v1/faqs/categories", { params }); apiGet("/api/v1/faqs/categories", { params });
@@ -36,6 +57,9 @@ export const createFaqCategory = (payload: Record<string, any>) =>
export const updateFaqCategory = (categoryId: string, payload: Record<string, any>) => export const updateFaqCategory = (categoryId: string, payload: Record<string, any>) =>
apiPatch<FaqCategory>(`/api/v1/faqs/categories/${categoryId}`, payload); apiPatch<FaqCategory>(`/api/v1/faqs/categories/${categoryId}`, payload);
export const deleteFaqCategory = (categoryId: string) =>
apiDelete<void>(`/api/v1/faqs/categories/${categoryId}`);
export const fetchFaqItems = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqItem>>> => export const fetchFaqItems = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqItem>>> =>
apiGet("/api/v1/faqs/items", { params }); apiGet("/api/v1/faqs/items", { params });
@@ -45,3 +69,21 @@ export const createFaqItem = (payload: Record<string, any>) => apiPost<FaqItem>(
export const updateFaqItem = (itemId: string, payload: Record<string, any>) => export const updateFaqItem = (itemId: string, payload: Record<string, any>) =>
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}`, payload); apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}`, payload);
export const deleteFaqItem = (itemId: string) =>
apiDelete<void>(`/api/v1/faqs/items/${itemId}`);
export const fetchFaqReplies = (itemId: string) =>
apiGet<FaqReply[] | { items: FaqReply[] }>(`/api/v1/faqs/items/${itemId}/replies`);
export const createFaqReply = (itemId: string, payload: { content: string; quote_reply_id?: string | null }) =>
apiPost<FaqReply>(`/api/v1/faqs/items/${itemId}/replies`, payload);
export const deleteFaqReply = (itemId: string, replyId: string) =>
apiDelete<void>(`/api/v1/faqs/items/${itemId}/replies/${replyId}`);
export const setFaqBestReply = (itemId: string, payload: { best_reply_id: string | null }) =>
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}/best-reply`, payload);
export const setFaqStatus = (itemId: string, payload: { status: "PENDING" | "PROCESSING" | "RESOLVED" }) =>
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}/status`, payload);
+11 -5
View File
@@ -49,7 +49,7 @@ const rules: FormRules = {
const isEdit = computed(() => !!props.category); const isEdit = computed(() => !!props.category);
const reset = () => { const reset = () => {
form.study_id = props.isAdmin ? "" : study.currentStudy?.id || ""; form.study_id = study.currentStudy?.id || "";
form.name = ""; form.name = "";
form.description = ""; form.description = "";
form.sort_order = 0; form.sort_order = 0;
@@ -72,6 +72,15 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(
() => props.modelValue,
(val) => {
if (val && !props.category) {
reset();
}
}
);
const close = () => emit("update:modelValue", false); const close = () => emit("update:modelValue", false);
const onSubmit = async () => { const onSubmit = async () => {
@@ -80,15 +89,12 @@ const onSubmit = async () => {
submitting.value = true; submitting.value = true;
try { try {
const payload: Record<string, any> = { const payload: Record<string, any> = {
study_id: form.study_id || null, study_id: study.currentStudy?.id || null,
name: form.name, name: form.name,
description: form.description || null, description: form.description || null,
sort_order: form.sort_order, sort_order: form.sort_order,
is_active: form.is_active, is_active: form.is_active,
}; };
if (!props.isAdmin) {
payload.study_id = study.currentStudy?.id || null;
}
if (isEdit.value && props.category) { if (isEdit.value && props.category) {
await updateFaqCategory(props.category.id, payload); await updateFaqCategory(props.category.id, payload);
} else { } else {
+44 -16
View File
@@ -8,22 +8,18 @@
</PermissionAction> </PermissionAction>
</div> </div>
</div> </div>
<el-menu :default-active="activeCategory" @select="onSelect"> <el-menu :default-active="activeCategory" @select="onSelect" class="menu">
<el-menu-item index="">全部</el-menu-item> <el-menu-item index="">全部</el-menu-item>
<el-menu-item v-for="c in sortedCategories" :key="c.id" :index="c.id"> <el-menu-item v-for="c in sortedCategories" :key="c.id" :index="c.id">
<span>{{ c.name }}</span> <span class="name" :title="c.name">{{ c.name }}</span>
<el-tag v-if="c.study_id" size="small" type="success" class="ml-4">项目</el-tag> <div v-if="canEdit(c) || canDelete" class="actions">
<el-tag v-else size="small" class="ml-4">全局</el-tag> <PermissionAction v-if="canEdit(c)" action="faq.edit">
<PermissionAction v-if="canEdit(c)" action="faq.edit"> <el-button type="text" size="small" @click.stop="openForm(c)">编辑</el-button>
<el-button </PermissionAction>
type="text" <PermissionAction v-if="canDelete" action="faq.edit">
size="small" <el-button type="text" size="small" class="danger" @click.stop="onDelete(c)">删除</el-button>
style="margin-left: auto" </PermissionAction>
@click.stop="openForm(c)" </div>
>
编辑
</el-button>
</PermissionAction>
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
<FaqCategoryForm v-model="showForm" :category="editing" :is-admin="isAdmin" @success="emit('refresh')" /> <FaqCategoryForm v-model="showForm" :category="editing" :is-admin="isAdmin" @success="emit('refresh')" />
@@ -32,8 +28,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FaqCategoryForm from "./FaqCategoryForm.vue"; import FaqCategoryForm from "./FaqCategoryForm.vue";
import type { FaqCategory } from "../api/faqs"; import type { FaqCategory } from "../api/faqs";
import { deleteFaqCategory } from "../api/faqs";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import PermissionAction from "./PermissionAction.vue"; import PermissionAction from "./PermissionAction.vue";
@@ -54,6 +52,7 @@ const editing = ref<FaqCategory | null>(null);
const { can } = usePermission(); const { can } = usePermission();
const isAdmin = computed(() => auth.user?.role === "ADMIN"); const isAdmin = computed(() => auth.user?.role === "ADMIN");
const canMaintain = computed(() => can("faq.edit")); const canMaintain = computed(() => can("faq.edit"));
const canDelete = computed(() => isAdmin.value);
const sortedCategories = computed(() => const sortedCategories = computed(() =>
[...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) [...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
@@ -75,6 +74,18 @@ const openForm = (cat?: FaqCategory) => {
editing.value = cat || null; editing.value = cat || null;
showForm.value = true; showForm.value = true;
}; };
const onDelete = async (cat: FaqCategory) => {
const ok = await ElMessageBox.confirm(`确认删除分类「${cat.name}」?`, "提示").catch(() => null);
if (!ok) return;
try {
await deleteFaqCategory(cat.id);
ElMessage.success("删除成功");
emit("refresh");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
</script> </script>
<style scoped> <style scoped>
@@ -88,7 +99,24 @@ const openForm = (cat?: FaqCategory) => {
padding: 12px; padding: 12px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
} }
.ml-4 { .menu {
margin-left: 4px; max-height: calc(100vh - 240px);
overflow: auto;
}
.actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.danger {
color: #f56c6c;
} }
</style> </style>
+1 -16
View File
@@ -9,12 +9,6 @@
<el-form-item label="问题" prop="question"> <el-form-item label="问题" prop="question">
<el-input v-model="form.question" /> <el-input v-model="form.question" />
</el-form-item> </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-form-item label="启用">
<el-switch v-model="form.is_active" /> <el-switch v-model="form.is_active" />
</el-form-item> </el-form-item>
@@ -43,15 +37,12 @@ const form = reactive({
study_id: "", study_id: "",
category_id: "", category_id: "",
question: "", question: "",
answer: "",
keywords: "",
is_active: true, is_active: true,
}); });
const rules: FormRules = { const rules: FormRules = {
category_id: [{ required: true, message: "请选择分类", trigger: "change" }], category_id: [{ required: true, message: "请选择分类", trigger: "change" }],
question: [{ required: true, message: "请输入问题", trigger: "blur" }], question: [{ required: true, message: "请输入问题", trigger: "blur" }],
answer: [{ required: true, message: "请输入答案", trigger: "blur" }],
}; };
const isEdit = computed(() => !!props.item); const isEdit = computed(() => !!props.item);
@@ -60,8 +51,6 @@ const reset = () => {
form.study_id = study.currentStudy?.id || ""; form.study_id = study.currentStudy?.id || "";
form.category_id = ""; form.category_id = "";
form.question = ""; form.question = "";
form.answer = "";
form.keywords = "";
form.is_active = true; form.is_active = true;
}; };
@@ -72,8 +61,6 @@ watch(
form.study_id = val.study_id || ""; form.study_id = val.study_id || "";
form.category_id = val.category_id; form.category_id = val.category_id;
form.question = val.question; form.question = val.question;
form.answer = val.answer;
form.keywords = val.keywords || "";
form.is_active = val.is_active; form.is_active = val.is_active;
} else { } else {
reset(); reset();
@@ -90,11 +77,9 @@ const onSubmit = async () => {
submitting.value = true; submitting.value = true;
try { try {
const payload: Record<string, any> = { const payload: Record<string, any> = {
study_id: form.study_id || null, study_id: study.currentStudy?.id || null,
category_id: form.category_id, category_id: form.category_id,
question: form.question, question: form.question,
answer: form.answer,
keywords: form.keywords || null,
is_active: form.is_active, is_active: form.is_active,
}; };
if (isEdit.value && props.item) { if (isEdit.value && props.item) {
+59 -11
View File
@@ -1,12 +1,17 @@
<template> <template>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%" @row-click="onRowClick">
<el-table-column prop="question" label="问题" min-width="200" /> <el-table-column prop="question" label="问题" min-width="200">
<template #default="scope">
<el-link type="primary" :underline="false" class="question-link">
{{ scope.row.question }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="category_id" label="分类" width="140"> <el-table-column prop="category_id" label="分类" width="140">
<template #default="scope"> <template #default="scope">
{{ categoryMap[scope.row.category_id] || scope.row.category_id }} {{ categoryMap[scope.row.category_id] || scope.row.category_id }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="version" label="版本" width="80" />
<el-table-column prop="is_active" label="启用" width="80"> <el-table-column prop="is_active" label="启用" width="80">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag> <el-tag :type="scope.row.is_active ? 'success' : 'info'">{{ scope.row.is_active ? "是" : "否" }}</el-tag>
@@ -15,12 +20,16 @@
<el-table-column prop="updated_at" label="更新时间" width="180"> <el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template> <template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180"> <el-table-column label="操作" width="180">
<template #default="scope"> <template #default="scope">
<el-button type="text" size="small" @click="view(scope.row)">查看</el-button> <el-button v-if="canDelete(scope.row)" type="text" size="small" class="danger" @click="remove(scope.row)">
<PermissionAction v-if="canEdit" action="faq.edit"> 删除
<el-button type="text" size="small" @click="edit(scope.row)">编辑</el-button> </el-button>
</PermissionAction>
<PermissionAction v-if="canEdit" action="faq.edit"> <PermissionAction v-if="canEdit" action="faq.edit">
<el-button <el-button
type="text" type="text"
@@ -40,10 +49,11 @@
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { computed } from "vue"; import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { updateFaqItem } from "../api/faqs"; import { deleteFaqItem, updateFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs"; import type { FaqItem, FaqCategory } from "../api/faqs";
import PermissionAction from "./PermissionAction.vue"; import PermissionAction from "./PermissionAction.vue";
import { displayDateTime } from "../utils/display"; import { displayDateTime } from "../utils/display";
import { useAuthStore } from "../store/auth";
const props = defineProps<{ const props = defineProps<{
items: FaqItem[]; items: FaqItem[];
@@ -51,9 +61,10 @@ const props = defineProps<{
loading: boolean; loading: boolean;
canEdit: boolean; canEdit: boolean;
}>(); }>();
const emit = defineEmits(["refresh", "edit"]); const emit = defineEmits(["refresh"]);
const router = useRouter(); const router = useRouter();
const auth = useAuthStore();
const categoryMap = computed(() => const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => { props.categories.reduce<Record<string, string>>((acc, cur) => {
@@ -66,10 +77,26 @@ const view = (row: FaqItem) => {
router.push(`/study/faq/${row.id}`); router.push(`/study/faq/${row.id}`);
}; };
const edit = (row: FaqItem) => { const onRowClick = (row: FaqItem, column: any, event: MouseEvent) => {
emit("edit", row); if (column?.property !== "question") return;
view(row);
}; };
const statusLabel = (status?: string) => {
if (status === "RESOLVED") return "已解决";
if (status === "PROCESSING") return "处理中";
return "待回答";
};
const statusTagType = (status?: string) => {
if (status === "RESOLVED") return "success";
if (status === "PROCESSING") return "warning";
return "info";
};
const canDelete = (row: FaqItem) => props.canEdit || row.created_by === auth.user?.id;
const toggle = async (row: FaqItem) => { const toggle = async (row: FaqItem) => {
const target = !row.is_active; const target = !row.is_active;
const ok = await ElMessageBox.confirm(`确认${target ? "启用" : "停用"}此 FAQ?`, "提示").catch(() => null); const ok = await ElMessageBox.confirm(`确认${target ? "启用" : "停用"}此 FAQ?`, "提示").catch(() => null);
@@ -82,4 +109,25 @@ const toggle = async (row: FaqItem) => {
ElMessage.error(e?.response?.data?.message || "操作失败"); ElMessage.error(e?.response?.data?.message || "操作失败");
} }
}; };
const remove = async (row: FaqItem) => {
const ok = await ElMessageBox.confirm(`确认删除 FAQ「${row.question}」?`, "提示").catch(() => null);
if (!ok) return;
try {
await deleteFaqItem(row.id);
ElMessage.success("问题已删除");
emit("refresh");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
</script> </script>
<style scoped>
.danger {
color: #f56c6c;
}
.question-link {
cursor: pointer;
}
</style>
+4
View File
@@ -17,6 +17,8 @@ const PERMISSIONS: Record<string, string[]> = {
"finance.pay": ["ADMIN", "PM"], "finance.pay": ["ADMIN", "PM"],
"imp.transaction": ["ADMIN", "PM", "IMP"], "imp.transaction": ["ADMIN", "PM", "IMP"],
"faq.edit": ["ADMIN", "PM"], "faq.edit": ["ADMIN", "PM"],
"faq.create": ["ADMIN", "PM", "CRA", "PV", "IMP"],
"faq.reply": ["ADMIN", "PM", "CRA", "PV", "IMP"],
"dataquery.edit": ["ADMIN", "PM", "CRA"], "dataquery.edit": ["ADMIN", "PM", "CRA"],
"project.members.manage": ["ADMIN", "PM"], "project.members.manage": ["ADMIN", "PM"],
"site.manage": ["ADMIN", "PM"], "site.manage": ["ADMIN", "PM"],
@@ -33,6 +35,8 @@ const REASONS: Record<string, string> = {
"finance.pay": "支付确认需要 PM/ADMIN 权限", "finance.pay": "支付确认需要 PM/ADMIN 权限",
"imp.transaction": "仅 IMP/PM/ADMIN 可操作台账", "imp.transaction": "仅 IMP/PM/ADMIN 可操作台账",
"faq.edit": "仅 PM/ADMIN 可维护 FAQ", "faq.edit": "仅 PM/ADMIN 可维护 FAQ",
"faq.create": "仅项目成员可新建 FAQ",
"faq.reply": "仅项目成员可回复 FAQ",
"dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题", "dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题",
"project.members.manage": "仅 ADMIN / PM 可调整项目成员", "project.members.manage": "仅 ADMIN / PM 可调整项目成员",
"site.manage": "仅 ADMIN / PM 可管理中心", "site.manage": "仅 ADMIN / PM 可管理中心",
+10 -12
View File
@@ -1,14 +1,14 @@
<template> <template>
<div class="page"> <div class="page">
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="6"> <el-col :span="5">
<FaqCategoryPanel <FaqCategoryPanel
v-model="activeCategory" v-model="activeCategory"
:categories="categories" :categories="categories"
@refresh="loadCategories" @refresh="loadCategories"
/> />
</el-col> </el-col>
<el-col :span="18"> <el-col :span="19">
<el-card class="mb-12"> <el-card class="mb-12">
<div class="filters"> <div class="filters">
<el-input <el-input
@@ -18,14 +18,9 @@
@keyup.enter="loadFaqs" @keyup.enter="loadFaqs"
style="width: 260px" 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" /> <el-switch v-model="onlyActive" active-text="仅启用" @change="loadFaqs" />
<div class="spacer" /> <div class="spacer" />
<PermissionAction action="faq.edit"> <PermissionAction action="faq.create">
<el-button type="primary" @click="openForm()">新建 FAQ</el-button> <el-button type="primary" @click="openForm()">新建 FAQ</el-button>
</PermissionAction> </PermissionAction>
</div> </div>
@@ -37,7 +32,6 @@
:loading="loading" :loading="loading"
:can-edit="canEdit" :can-edit="canEdit"
@refresh="loadFaqs" @refresh="loadFaqs"
@edit="openForm"
/> />
<el-pagination <el-pagination
class="pagination" class="pagination"
@@ -55,7 +49,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { fetchFaqCategories, fetchFaqItems } from "../api/faqs"; import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue"; import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
@@ -77,7 +71,6 @@ const pageSize = 10;
const total = ref(0); const total = ref(0);
const keyword = ref(""); const keyword = ref("");
const onlyActive = ref(true); const onlyActive = ref(true);
const studyScope = ref("project");
const activeCategory = ref(""); const activeCategory = ref("");
const showForm = ref(false); const showForm = ref(false);
const editing = ref<any | null>(null); const editing = ref<any | null>(null);
@@ -89,6 +82,7 @@ const loadCategories = async () => {
try { try {
const params: Record<string, any> = {}; const params: Record<string, any> = {};
if (study.currentStudy) params.study_id = study.currentStudy.id; if (study.currentStudy) params.study_id = study.currentStudy.id;
params.include_global = false;
const { data } = await fetchFaqCategories(params); const { data } = await fetchFaqCategories(params);
categories.value = data.items || data || []; categories.value = data.items || data || [];
} catch (e: any) { } catch (e: any) {
@@ -108,7 +102,6 @@ const loadFaqs = async () => {
if (activeCategory.value) params.category_id = activeCategory.value; if (activeCategory.value) params.category_id = activeCategory.value;
if (keyword.value) params.keyword = keyword.value; if (keyword.value) params.keyword = keyword.value;
if (onlyActive.value) params.is_active = true; if (onlyActive.value) params.is_active = true;
if (studyScope.value) params.study_scope = studyScope.value;
const { data } = await fetchFaqItems(params); const { data } = await fetchFaqItems(params);
if (Array.isArray(data)) { if (Array.isArray(data)) {
faqs.value = data; faqs.value = data;
@@ -129,6 +122,11 @@ const onPageChange = (p: number) => {
loadFaqs(); loadFaqs();
}; };
watch(activeCategory, () => {
page.value = 1;
loadFaqs();
});
const openForm = (row?: any) => { const openForm = (row?: any) => {
editing.value = row || null; editing.value = row || null;
showForm.value = true; showForm.value = true;
+453 -26
View File
@@ -1,52 +1,247 @@
<template> <template>
<div class="page"> <div class="page">
<el-card v-loading="loading"> <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"> <div class="content">
<pre>{{ item?.answer }}</pre> <div class="question-header">
<div class="question-label">问题描述</div>
<div class="question-actions">
<el-button v-if="canEditQuestion" type="primary" size="small" class="edit-btn" @click="openEdit">
编辑问题
</el-button>
<el-button v-if="canConfirmResolved" type="success" size="small" @click="confirmResolved">
确认解决
</el-button>
</div>
</div>
<div class="question-meta">
<span class="meta-pill meta-user"
>提问人{{ displayUser(item?.created_by, { members: memberMap, users: userMap }) }}</span
>
<span class="meta-pill meta-time">提问时间{{ displayDateTime(item?.created_at) }}</span>
<span class="meta-pill meta-category">分类{{ categoryName }}</span>
<span class="meta-pill meta-status">状态{{ statusLabel }}</span>
</div>
<pre class="question-text">{{ item?.question }}</pre>
</div>
<div v-if="bestReply" class="best-answer">
<div class="best-title">
<span>最佳答案</span>
<el-tag type="success" size="small">已采纳</el-tag>
</div>
<div class="best-meta">
{{ displayUser(bestReply.created_by, { members: memberMap, users: userMap }) }}
· {{ displayDateTime(bestReply.created_at) }}
</div>
<div class="best-content">{{ bestReply.is_deleted ? "回复已删除" : bestReply.content }}</div>
</div> </div>
</el-card> </el-card>
<el-card class="mt-12" v-loading="repliesLoading">
<template #header>
<div class="reply-header">
<span>回复</span>
<span class="reply-count"> {{ replies.length }} </span>
</div>
</template>
<div v-if="canReply" class="reply-form">
<div v-if="quoteReply" class="quote-box">
<div class="quote-meta">
引用 {{ displayUser(quoteReply.created_by, { members: memberMap, users: userMap }) }}
· {{ displayDateTime(quoteReply.created_at) }}
<el-button type="text" size="small" @click="clearQuote">取消引用</el-button>
</div>
<div class="quote-content">{{ quoteReply.content }}</div>
</div>
<el-input v-model="replyContent" type="textarea" :rows="4" placeholder="请输入回复内容" />
<div class="reply-actions">
<el-button type="primary" :loading="submitting" @click="submitReply">提交回复</el-button>
</div>
</div>
<div v-else class="reply-disabled">当前角色无权限回复</div>
<el-divider v-if="replies.length" />
<el-timeline v-if="replies.length">
<el-timeline-item v-for="r in replies" :key="r.id" :timestamp="displayDateTime(r.created_at)">
<div class="reply-item">
<div class="reply-meta">
<strong>{{ displayUser(r.created_by, { members: memberMap, users: userMap }) }}</strong>
<div class="reply-actions-inline">
<el-tag v-if="item?.best_reply_id === r.id" type="success" size="small">最佳答案</el-tag>
<el-button v-if="canReply" type="text" size="small" @click="setQuote(r)">引用</el-button>
<el-button
v-if="canSelectBest && !r.is_deleted"
type="text"
size="small"
@click="toggleBest(r)"
>
{{ item?.best_reply_id === r.id ? "取消最佳" : "设为最佳" }}
</el-button>
<el-button v-if="canDeleteReply(r)" type="text" size="small" class="danger" @click="removeReply(r)">
删除
</el-button>
</div>
</div>
<div v-if="r.quote" class="quote-block">
<div class="quote-meta">
{{ displayUser(r.quote.created_by, { members: memberMap, users: userMap }) }}
· {{ displayDateTime(r.quote.created_at) }}
</div>
<div class="quote-content">{{ r.quote.content }}</div>
</div>
<div class="reply-content">{{ r.is_deleted ? "回复已删除" : r.content }}</div>
</div>
</el-timeline-item>
</el-timeline>
<div v-else class="reply-empty">暂无回复</div>
</el-card>
<FaqItemForm
v-model="showForm"
:item="editing"
:categories="categories"
@success="onEditSuccess"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchFaqItem, fetchFaqCategories } from "../api/faqs"; import {
createFaqReply,
deleteFaqReply,
fetchFaqItem,
fetchFaqReplies,
fetchFaqCategories,
setFaqBestReply,
setFaqStatus,
} from "../api/faqs";
import { listMembers } from "../api/members";
import { displayDateTime, displayUser } from "../utils/display";
import { useAuthStore } from "../store/auth";
import { usePermission } from "../utils/permission";
import FaqItemForm from "../components/FaqItemForm.vue";
const route = useRoute(); const route = useRoute();
const auth = useAuthStore();
const item = ref<any>(null); const item = ref<any>(null);
const loading = ref(false); const loading = ref(false);
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const replies = ref<any[]>([]);
const repliesLoading = ref(false);
const replyContent = ref("");
const submitting = ref(false);
const quoteReply = ref<any | null>(null);
const members = ref<any[]>([]);
const showForm = ref(false);
const editing = ref<any | null>(null);
const { can } = usePermission();
const canReply = computed(() => {
if (!item.value) return false;
if (!item.value.study_id) return auth.user?.role === "ADMIN";
return can("faq.reply");
});
const canDeleteReply = (reply: any) => {
if (reply.is_deleted) return false;
if (auth.user?.role === "ADMIN") return true;
if (reply.created_by === auth.user?.id) return true;
return item.value?.study_id ? can("faq.edit") : false;
};
const canSelectBest = computed(() => {
if (!item.value) return false;
if (!item.value.study_id) return auth.user?.role === "ADMIN";
return can("faq.reply");
});
const canEditQuestion = computed(() => {
if (!item.value) return false;
if (auth.user?.role === "ADMIN") return true;
if (item.value.created_by === auth.user?.id) return true;
return item.value?.study_id ? can("faq.edit") : false;
});
const canConfirmResolved = computed(() => {
if (!item.value) return false;
if (item.value.status === "RESOLVED") return false;
if (auth.user?.role === "ADMIN") return true;
if (item.value.created_by === auth.user?.id) return true;
return item.value?.study_id ? can("faq.edit") : false;
});
const statusLabel = computed(() => {
if (item.value?.status === "RESOLVED") return "已解决";
if (item.value?.status === "PROCESSING") return "处理中";
return "待回答";
});
const bestReply = computed(() => {
if (!item.value?.best_reply_id) return null;
return replies.value.find((r) => r.id === item.value.best_reply_id) || null;
});
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {})
);
const userMap = computed(() => {
if (!auth.user?.id) return {};
const name = auth.user.display_name || auth.user.username || auth.user.id;
return { [auth.user.id]: name };
});
const categoryName = computed(() => { const categoryName = computed(() => {
const cat = categories.value.find((c) => c.id === item.value?.category_id); const cat = categories.value.find((c) => c.id === item.value?.category_id);
return cat ? cat.name : item.value?.category_id; return cat ? cat.name : item.value?.category_id;
}); });
const loadMembers = async (studyId?: string | null) => {
if (!studyId) {
members.value = [];
return;
}
try {
const { data } = await listMembers(studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const loadReplies = async () => {
const id = route.params.itemId as string;
repliesLoading.value = true;
try {
const { data } = await fetchFaqReplies(id);
replies.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "回复加载失败");
} finally {
repliesLoading.value = false;
}
};
const loadData = async () => { const loadData = async () => {
const id = route.params.itemId as string; const id = route.params.itemId as string;
loading.value = true; loading.value = true;
try { try {
const [{ data: catData }, { data: faqData }] = await Promise.all([ const { data: faqData } = await fetchFaqItem(id);
fetchFaqCategories(),
fetchFaqItem(id),
]);
categories.value = catData.items || catData || [];
item.value = faqData; item.value = faqData;
const categoryParams: Record<string, any> = {};
if (faqData.study_id) {
categoryParams.study_id = faqData.study_id;
categoryParams.include_global = false;
}
const { data: catData } = await fetchFaqCategories(categoryParams);
categories.value = catData.items || catData || [];
await Promise.all([loadReplies(), loadMembers(faqData.study_id)]);
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || "加载失败"); ElMessage.error(e?.response?.data?.message || "加载失败");
} finally { } finally {
@@ -54,6 +249,87 @@ const loadData = async () => {
} }
}; };
const setQuote = (reply: any) => {
quoteReply.value = reply;
};
const clearQuote = () => {
quoteReply.value = null;
};
const submitReply = async () => {
if (!replyContent.value.trim()) {
ElMessage.warning("请输入回复内容");
return;
}
if (!item.value) return;
submitting.value = true;
try {
await createFaqReply(item.value.id, {
content: replyContent.value,
quote_reply_id: quoteReply.value?.id || null,
});
ElMessage.success("回复已提交");
replyContent.value = "";
quoteReply.value = null;
loadReplies();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "回复失败");
} finally {
submitting.value = false;
}
};
const removeReply = async (reply: any) => {
if (!item.value) return;
const ok = await ElMessageBox.confirm("确认删除该回复?", "提示").catch(() => null);
if (!ok) return;
try {
await deleteFaqReply(item.value.id, reply.id);
ElMessage.success("回复已删除");
loadReplies();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
}
};
const toggleBest = async (reply: any) => {
if (!item.value) return;
try {
const target = item.value.best_reply_id === reply.id ? null : reply.id;
if (target) {
const ok = await ElMessageBox.confirm("设为最佳答案并确认已解决?", "提示").catch(() => null);
if (!ok) return;
}
const { data } = await setFaqBestReply(item.value.id, { best_reply_id: target });
item.value = data;
ElMessage.success(target ? "已设为最佳答案" : "已取消最佳答案");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
};
const confirmResolved = async () => {
if (!item.value) return;
try {
const { data } = await setFaqStatus(item.value.id, { status: "RESOLVED" });
item.value = data;
ElMessage.success("已设置为已解决");
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
};
const openEdit = () => {
editing.value = item.value;
showForm.value = true;
};
const onEditSuccess = () => {
showForm.value = false;
loadData();
};
onMounted(() => { onMounted(() => {
loadData(); loadData();
}); });
@@ -63,16 +339,167 @@ onMounted(() => {
.page { .page {
padding: 16px; padding: 16px;
} }
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.content { .content {
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.6; line-height: 1.6;
} }
.question-label {
margin-bottom: 8px;
font-weight: 600;
color: #303133;
}
.question-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.question-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.edit-btn {
font-weight: 600;
}
.question-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: #606266;
font-size: 12px;
margin-bottom: 8px;
}
.meta-pill {
padding: 4px 10px;
border-radius: 999px;
font-weight: 500;
border: 1px solid transparent;
}
.meta-user {
background: #eef2ff;
border-color: #c7d2fe;
color: #3730a3;
}
.meta-time {
background: #fef9c3;
border-color: #fde68a;
color: #92400e;
}
.meta-category {
background: #ecfccb;
border-color: #bef264;
color: #3f6212;
}
.meta-status {
background: #ecfeff;
border-color: #a5f3fc;
color: #155e75;
}
.confirm-row {
margin-bottom: 8px;
}
.ml-8 { .ml-8 {
margin-left: 8px; margin-left: 8px;
} }
.mt-12 {
margin-top: 12px;
}
.reply-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.reply-count {
color: #909399;
font-size: 12px;
}
.reply-form {
margin-bottom: 12px;
}
.reply-actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.reply-disabled {
color: #909399;
font-size: 12px;
}
.reply-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.reply-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.reply-actions-inline {
display: flex;
gap: 8px;
align-items: center;
}
.reply-content {
white-space: pre-wrap;
}
.danger {
color: #f56c6c;
}
.best-answer {
margin-top: 16px;
padding: 12px 14px;
border: 1px solid #e1f3d8;
background: #f0f9eb;
border-radius: 8px;
}
.best-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #2f6f2f;
margin-bottom: 6px;
}
.best-meta {
color: #606266;
font-size: 12px;
margin-bottom: 8px;
}
.best-content {
white-space: pre-wrap;
}
.question-text {
margin: 6px 0 0;
padding: 12px 14px;
border-radius: 10px;
background: #f5f7ff;
border: 1px solid #e0e7ff;
color: #1f2a44;
font-size: 16px;
font-weight: 600;
}
.quote-block,
.quote-box {
background: #f5f7fa;
border-left: 3px solid #dcdfe6;
padding: 8px 10px;
border-radius: 4px;
}
.quote-meta {
display: flex;
justify-content: space-between;
color: #909399;
font-size: 12px;
margin-bottom: 6px;
}
.quote-content {
white-space: pre-wrap;
}
.reply-empty {
color: #909399;
font-size: 12px;
}
</style> </style>