feat(faq): refine category icons and project faq flows
This commit is contained in:
@@ -42,6 +42,10 @@ async def create_category(
|
||||
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
||||
if payload.icon:
|
||||
dup = await category_crud.get_category_by_icon(db, payload.study_id, payload.icon)
|
||||
if dup:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||
category = await category_crud.create_category(db, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
@@ -65,13 +69,12 @@ async def create_category(
|
||||
)
|
||||
async def list_categories(
|
||||
study_id: uuid.UUID | None = None,
|
||||
is_active: bool | None = True,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[CategoryRead]:
|
||||
if not study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active)
|
||||
categories = await category_crud.list_categories(db, study_id, include_global=False)
|
||||
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
|
||||
|
||||
|
||||
@@ -102,6 +105,10 @@ async def update_category(
|
||||
existing = await category_crud.get_category_by_name(db, target_study_id, target_name)
|
||||
if existing and existing.id != category.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
||||
if payload.icon:
|
||||
dup = await category_crud.get_category_by_icon(db, target_study_id, payload.icon)
|
||||
if dup and dup.id != category.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||
if not target_study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
updated = await category_crud.update_category(db, category, payload)
|
||||
|
||||
@@ -90,7 +90,6 @@ async def list_faqs(
|
||||
study_id: uuid.UUID | None = None,
|
||||
category_id: uuid.UUID | None = None,
|
||||
keyword: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[FaqRead]:
|
||||
@@ -111,17 +110,12 @@ async def list_faqs(
|
||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not member or not member.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
if is_active is False:
|
||||
allowed = await role_has_api_permission(db, study_id, member.role_in_study, "faq:update", check_prerequisites=False)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
items = await faq_crud.list_items(
|
||||
db,
|
||||
study_id=study_id,
|
||||
category_id=category_id,
|
||||
keyword=keyword,
|
||||
is_active=is_active,
|
||||
study_scope="project",
|
||||
)
|
||||
|
||||
@@ -131,10 +125,6 @@ async def list_faqs(
|
||||
role = await _get_member_role(it.study_id)
|
||||
if not role:
|
||||
continue
|
||||
if not it.is_active and not _is_system_admin(current_user):
|
||||
allowed = await role_has_api_permission(db, it.study_id, role, "faq:update", check_prerequisites=False)
|
||||
if not allowed:
|
||||
continue
|
||||
visible.append(FaqRead.model_validate(it))
|
||||
return paginate(visible, total=len(visible))
|
||||
|
||||
@@ -156,13 +146,6 @@ async def get_faq(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if not item.study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if not item.is_active and not is_system_admin(current_user):
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
if not member or not member.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
allowed = await role_has_api_permission(db, item.study_id, member.role_in_study, "faq:update", check_prerequisites=False)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
return FaqRead.model_validate(item)
|
||||
|
||||
|
||||
@@ -185,13 +168,9 @@ async def update_faq(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
old_active = item.is_active
|
||||
updated = await faq_crud.update_item(db, item, payload)
|
||||
action = "UPDATE_FAQ_ITEM"
|
||||
detail = "FAQ updated"
|
||||
if payload.is_active is not None and payload.is_active != old_active:
|
||||
action = "FAQ_STATUS_CHANGE"
|
||||
detail = "FAQ disabled" if not payload.is_active else "FAQ enabled"
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
|
||||
@@ -14,7 +14,7 @@ async def create_category(db: AsyncSession, category_in: CategoryCreate) -> FaqC
|
||||
name=category_in.name,
|
||||
description=category_in.description,
|
||||
sort_order=category_in.sort_order,
|
||||
is_active=category_in.is_active,
|
||||
icon=category_in.icon,
|
||||
)
|
||||
db.add(category)
|
||||
await db.commit()
|
||||
@@ -26,15 +26,12 @@ async def list_categories(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID | None,
|
||||
include_global: bool = True,
|
||||
is_active: bool | None = True,
|
||||
) -> Sequence[FaqCategory]:
|
||||
stmt = select(FaqCategory)
|
||||
if include_global:
|
||||
stmt = stmt.where((FaqCategory.study_id == study_id) | (FaqCategory.study_id.is_(None)))
|
||||
else:
|
||||
stmt = stmt.where(FaqCategory.study_id == study_id)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(FaqCategory.is_active == is_active)
|
||||
stmt = stmt.order_by(FaqCategory.sort_order, FaqCategory.name)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@@ -59,6 +56,22 @@ async def get_category_by_name(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_category_by_icon(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID | None,
|
||||
icon: str,
|
||||
) -> FaqCategory | None:
|
||||
if not icon:
|
||||
return None
|
||||
stmt = select(FaqCategory).where(FaqCategory.icon == icon)
|
||||
if study_id is None:
|
||||
stmt = stmt.where(FaqCategory.study_id.is_(None))
|
||||
else:
|
||||
stmt = stmt.where(FaqCategory.study_id == study_id)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_category(db: AsyncSession, category: FaqCategory, category_in: CategoryUpdate) -> FaqCategory:
|
||||
update_data = category_in.model_dump(exclude_unset=True)
|
||||
if update_data:
|
||||
|
||||
@@ -26,7 +26,6 @@ async def create_item(db: AsyncSession, item_in: FaqCreate, *, created_by: uuid.
|
||||
answer=item_in.answer or "",
|
||||
status="PENDING",
|
||||
version=1,
|
||||
is_active=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(item)
|
||||
@@ -46,7 +45,6 @@ async def list_items(
|
||||
study_id: uuid.UUID | None,
|
||||
category_id: uuid.UUID | None = None,
|
||||
keyword: str | None = None,
|
||||
is_active: bool | None = True,
|
||||
study_scope: str | None = "project",
|
||||
) -> Sequence[FaqItem]:
|
||||
stmt = select(FaqItem).join(FaqCategory, FaqCategory.id == FaqItem.category_id)
|
||||
@@ -69,8 +67,6 @@ async def list_items(
|
||||
FaqItem.keywords.ilike(like_pattern),
|
||||
)
|
||||
)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(FaqItem.is_active == is_active)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -20,7 +20,7 @@ class FaqCategory(Base):
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
|
||||
@@ -26,7 +26,6 @@ class FaqItem(Base):
|
||||
answer: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
keywords: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -10,7 +10,7 @@ class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
@@ -18,7 +18,7 @@ class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryRead(BaseModel):
|
||||
@@ -27,7 +27,7 @@ class CategoryRead(BaseModel):
|
||||
name: str
|
||||
description: Optional[str]
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
icon: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -44,7 +44,6 @@ class FaqCreate(BaseModel):
|
||||
class FaqUpdate(BaseModel):
|
||||
question: Optional[str] = None
|
||||
answer: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class FaqStatusUpdate(BaseModel):
|
||||
@@ -94,7 +93,6 @@ class FaqRead(BaseModel):
|
||||
answer: str
|
||||
keywords: Optional[str]
|
||||
version: int
|
||||
is_active: bool
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
<title>CTMS</title>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const apiPost = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
const apiDelete = vi.fn();
|
||||
const apiGet = vi.fn();
|
||||
|
||||
vi.mock("./axios", () => ({
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPatch,
|
||||
apiDelete,
|
||||
@@ -54,4 +56,34 @@ describe("faqs item api", () => {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes study_id as a query parameter for item detail permission checks", async () => {
|
||||
const { fetchFaqItem, fetchFaqReplies, createFaqReply, deleteFaqReply, setFaqBestReply, setFaqStatus } = await import("./faqs");
|
||||
|
||||
fetchFaqItem("item-1", "study-1");
|
||||
fetchFaqReplies("item-1", "study-1");
|
||||
createFaqReply("item-1", "study-1", { content: "建议复查。" });
|
||||
deleteFaqReply("item-1", "reply-1", "study-1");
|
||||
setFaqBestReply("item-1", "study-1", { best_reply_id: "reply-1" });
|
||||
setFaqStatus("item-1", "study-1", { status: "RESOLVED" });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPost).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies", { content: "建议复查。" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiDelete).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/replies/reply-1", {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/best-reply", { best_reply_id: "reply-1" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
expect(apiPatch).toHaveBeenCalledWith("/api/v1/faqs/items/item-1/status", { status: "RESOLVED" }, {
|
||||
params: { study_id: "study-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-13
@@ -8,7 +8,7 @@ export interface FaqCategory {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
icon?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -23,7 +23,6 @@ export interface FaqItem {
|
||||
answer: string;
|
||||
keywords?: string | null;
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -65,7 +64,8 @@ export const deleteFaqCategory = (categoryId: string, studyId?: string | null) =
|
||||
export const fetchFaqItems = (params?: Record<string, any>): Promise<AxiosResponse<ApiListResponse<FaqItem>>> =>
|
||||
apiGet("/api/v1/faqs/items/", { params });
|
||||
|
||||
export const fetchFaqItem = (itemId: string) => apiGet<FaqItem>(`/api/v1/faqs/items/${itemId}`);
|
||||
export const fetchFaqItem = (itemId: string, studyId?: string | null) =>
|
||||
apiGet<FaqItem>(`/api/v1/faqs/items/${itemId}`, studyQuery(studyId));
|
||||
|
||||
export const createFaqItem = (payload: Record<string, any>) =>
|
||||
apiPost<FaqItem>("/api/v1/faqs/items/", payload, studyQuery(payload.study_id));
|
||||
@@ -76,17 +76,17 @@ export const updateFaqItem = (itemId: string, payload: Record<string, any>) =>
|
||||
export const deleteFaqItem = (itemId: string, studyId?: string | null) =>
|
||||
apiDelete<void>(`/api/v1/faqs/items/${itemId}`, studyQuery(studyId));
|
||||
|
||||
export const fetchFaqReplies = (itemId: string) =>
|
||||
apiGet<FaqReply[] | { items: FaqReply[] }>(`/api/v1/faqs/items/${itemId}/replies`);
|
||||
export const fetchFaqReplies = (itemId: string, studyId?: string | null) =>
|
||||
apiGet<FaqReply[] | { items: FaqReply[] }>(`/api/v1/faqs/items/${itemId}/replies`, studyQuery(studyId));
|
||||
|
||||
export const createFaqReply = (itemId: string, payload: { content: string; quote_reply_id?: string | null }) =>
|
||||
apiPost<FaqReply>(`/api/v1/faqs/items/${itemId}/replies`, payload);
|
||||
export const createFaqReply = (itemId: string, studyId: string | null | undefined, payload: { content: string; quote_reply_id?: string | null }) =>
|
||||
apiPost<FaqReply>(`/api/v1/faqs/items/${itemId}/replies`, payload, studyQuery(studyId));
|
||||
|
||||
export const deleteFaqReply = (itemId: string, replyId: string) =>
|
||||
apiDelete<void>(`/api/v1/faqs/items/${itemId}/replies/${replyId}`);
|
||||
export const deleteFaqReply = (itemId: string, replyId: string, studyId?: string | null) =>
|
||||
apiDelete<void>(`/api/v1/faqs/items/${itemId}/replies/${replyId}`, studyQuery(studyId));
|
||||
|
||||
export const setFaqBestReply = (itemId: string, payload: { best_reply_id: string | null }) =>
|
||||
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}/best-reply`, payload);
|
||||
export const setFaqBestReply = (itemId: string, studyId: string | null | undefined, payload: { best_reply_id: string | null }) =>
|
||||
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}/best-reply`, payload, studyQuery(studyId));
|
||||
|
||||
export const setFaqStatus = (itemId: string, payload: { status: "PENDING" | "PROCESSING" | "RESOLVED" }) =>
|
||||
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}/status`, payload);
|
||||
export const setFaqStatus = (itemId: string, studyId: string | null | undefined, payload: { status: "PENDING" | "PROCESSING" | "RESOLVED" }) =>
|
||||
apiPatch<FaqItem>(`/api/v1/faqs/items/${itemId}/status`, payload, studyQuery(studyId));
|
||||
|
||||
@@ -30,13 +30,28 @@
|
||||
<el-form-item :label="TEXT.common.fields.sortOrder">
|
||||
<el-input-number v-model="form.sort_order" :min="0" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.enabled">
|
||||
<el-switch v-model="form.is_active" />
|
||||
<el-form-item label="图标">
|
||||
<div class="icon-picker">
|
||||
<span
|
||||
v-for="icon in iconOptions"
|
||||
:key="icon.cls"
|
||||
:title="icon.label"
|
||||
:class="['icon-option', {
|
||||
'icon-option--selected': form.icon === icon.cls,
|
||||
'icon-option--used': isIconUsedByOther(icon.cls),
|
||||
}]"
|
||||
@click="selectIcon(icon.cls)"
|
||||
><i :class="['fas', icon.cls]"></i></span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<div class="drawer-footer" :class="{ 'drawer-footer--edit': isEdit }">
|
||||
<el-button v-if="isEdit && canDelete" type="danger" plain :loading="deleting" @click="onDelete">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
<div class="drawer-footer-spacer" v-if="isEdit"></div>
|
||||
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
@@ -46,37 +61,71 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, FormInstance, FormRules } from "element-plus";
|
||||
import { createFaqCategory, updateFaqCategory } from "../api/faqs";
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { createFaqCategory, updateFaqCategory, deleteFaqCategory } from "../api/faqs";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; category?: any; isAdmin: boolean }>();
|
||||
const props = defineProps<{ modelValue: boolean; category?: any; isAdmin: boolean; canDelete?: boolean; existingIcons?: string[] }>();
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const study = useStudyStore();
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
study_id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
icon: "",
|
||||
});
|
||||
|
||||
const iconOptions = [
|
||||
{ cls: "fa-th-large", label: "全部" },
|
||||
{ cls: "fa-ruler-combined", label: "入排标准" },
|
||||
{ cls: "fa-calendar-check", label: "访视" },
|
||||
{ cls: "fa-filter", label: "筛选" },
|
||||
{ cls: "fa-flag-checkered", label: "启动会" },
|
||||
{ cls: "fa-capsules", label: "试验药物" },
|
||||
{ cls: "fa-prescription-bottle", label: "药品" },
|
||||
{ cls: "fa-vial", label: "实验室" },
|
||||
{ cls: "fa-heartbeat", label: "AE" },
|
||||
{ cls: "fa-pills", label: "合并用药" },
|
||||
{ cls: "fa-file-medical", label: "门诊病历" },
|
||||
{ cls: "fa-camera", label: "拍照" },
|
||||
{ cls: "fa-notes-medical", label: "医疗记录" },
|
||||
{ cls: "fa-clipboard-list", label: "检查表" },
|
||||
{ cls: "fa-syringe", label: "注射" },
|
||||
{ cls: "fa-flask", label: "研究" },
|
||||
{ cls: "fa-book-medical", label: "医学书籍" },
|
||||
{ cls: "fa-user-md", label: "医生" },
|
||||
{ cls: "fa-hospital", label: "医院" },
|
||||
{ cls: "fa-ellipsis-h", label: "其他" },
|
||||
];
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: requiredMessage(TEXT.modules.knowledgeMedicalConsult.categoryName), trigger: "blur" }],
|
||||
};
|
||||
|
||||
const isEdit = computed(() => !!props.category);
|
||||
|
||||
const isIconUsedByOther = (icon: string) => {
|
||||
if (form.icon === icon) return false;
|
||||
return (props.existingIcons || []).includes(icon);
|
||||
};
|
||||
|
||||
const selectIcon = (icon: string) => {
|
||||
if (isIconUsedByOther(icon)) return;
|
||||
form.icon = form.icon === icon ? "" : icon;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
form.name = "";
|
||||
form.description = "";
|
||||
form.sort_order = 0;
|
||||
form.is_active = true;
|
||||
form.icon = "";
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -86,7 +135,7 @@ watch(
|
||||
form.name = val.name;
|
||||
form.description = val.description || "";
|
||||
form.sort_order = val.sort_order || 0;
|
||||
form.is_active = val.is_active;
|
||||
form.icon = val.icon || "";
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
@@ -122,7 +171,7 @@ const onSubmit = async () => {
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
sort_order: form.sort_order,
|
||||
is_active: form.is_active,
|
||||
icon: form.icon || null,
|
||||
};
|
||||
if (isEdit.value && props.category) {
|
||||
await updateFaqCategory(props.category.id, payload);
|
||||
@@ -139,6 +188,26 @@ const onSubmit = async () => {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!props.category) return;
|
||||
const ok = await ElMessageBox.confirm(
|
||||
TEXT.modules.knowledgeMedicalConsult.confirmDeleteCategory.replace("{name}", props.category.name),
|
||||
TEXT.common.labels.tips,
|
||||
).catch(() => null);
|
||||
if (!ok) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteFaqCategory(props.category.id, study.currentStudy?.id);
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
emit("success");
|
||||
close();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -184,6 +253,57 @@ const onSubmit = async () => {
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.drawer-footer--edit {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.drawer-footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 2px solid #e8eef6;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
color: #697982;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.icon-option:hover {
|
||||
border-color: #93c5fd;
|
||||
background: #eff6ff;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.icon-option--selected {
|
||||
border-color: #3b82f6;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.icon-option--used {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
border-color: #e8eef6;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-if="modelValue"
|
||||
:model-value="modelValue"
|
||||
direction="rtl"
|
||||
size="560px"
|
||||
:close-on-click-modal="true"
|
||||
:show-close="false"
|
||||
class="faq-category-manager-drawer"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="editor-header">
|
||||
<div class="editor-title-row">
|
||||
<div class="editor-title">分类管理</div>
|
||||
<el-button size="small" class="header-add-btn" @click="addCategory">
|
||||
<i class="fas fa-plus"></i> 新增
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="manager-body">
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="(cat, idx) in editableCategories"
|
||||
:key="cat._key"
|
||||
class="category-row"
|
||||
:class="{ 'category-row--dirty': cat._dirty }"
|
||||
>
|
||||
<div class="row-grip">
|
||||
<span class="sort-num">{{ idx + 1 }}</span>
|
||||
<div class="sort-arrows">
|
||||
<button class="sort-btn" :disabled="idx === 0" @click="moveUp(idx)" title="上移">
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
<button class="sort-btn" :disabled="idx === editableCategories.length - 1" @click="moveDown(idx)" title="下移">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-popover
|
||||
placement="bottom-start"
|
||||
:width="320"
|
||||
trigger="click"
|
||||
:teleported="true"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="row-icon" :class="{ 'row-icon--selected': cat.icon }" title="选择图标">
|
||||
<i v-if="cat.icon" :class="['fas', cat.icon]"></i>
|
||||
<span v-else class="row-icon-placeholder"><i class="fas fa-icons"></i></span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="icon-popover-grid">
|
||||
<span
|
||||
v-for="icon in iconOptions"
|
||||
:key="icon.cls"
|
||||
:title="icon.label"
|
||||
:class="['mini-icon', { 'mini-icon--active': cat.icon === icon.cls }]"
|
||||
@click="selectIconFor(cat, icon.cls)"
|
||||
><i :class="['fas', icon.cls]"></i></span>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
<div class="row-main">
|
||||
<el-input
|
||||
v-model="cat.name"
|
||||
size="small"
|
||||
placeholder="分类名称"
|
||||
class="row-name-input"
|
||||
@input="cat._dirty = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="row-delete"
|
||||
type="button"
|
||||
@click.stop="removeCategory(cat)"
|
||||
><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveAll">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { createFaqCategory, updateFaqCategory, deleteFaqCategory } from "../api/faqs";
|
||||
import type { FaqCategory } from "../api/faqs";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
interface EditableCategory {
|
||||
_key: string;
|
||||
_dirty: boolean;
|
||||
_deleted: boolean;
|
||||
_isNew: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
sort_order: number;
|
||||
icon?: string | null;
|
||||
study_id?: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
categories: FaqCategory[];
|
||||
faqs?: any[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const study = useStudyStore();
|
||||
const saving = ref(false);
|
||||
let keyCounter = 0;
|
||||
|
||||
const editableCategories = ref<EditableCategory[]>([]);
|
||||
|
||||
const iconOptions = [
|
||||
{ cls: "fa-th-large", label: "全部" },
|
||||
{ cls: "fa-ruler-combined", label: "入排标准" },
|
||||
{ cls: "fa-calendar-check", label: "访视" },
|
||||
{ cls: "fa-filter", label: "筛选" },
|
||||
{ cls: "fa-flag-checkered", label: "启动会" },
|
||||
{ cls: "fa-capsules", label: "试验药物" },
|
||||
{ cls: "fa-prescription-bottle", label: "药品" },
|
||||
{ cls: "fa-vial", label: "实验室" },
|
||||
{ cls: "fa-heartbeat", label: "AE" },
|
||||
{ cls: "fa-pills", label: "合并用药" },
|
||||
{ cls: "fa-file-medical", label: "门诊病历" },
|
||||
{ cls: "fa-camera", label: "拍照" },
|
||||
{ cls: "fa-notes-medical", label: "医疗记录" },
|
||||
{ cls: "fa-clipboard-list", label: "检查表" },
|
||||
{ cls: "fa-syringe", label: "注射" },
|
||||
{ cls: "fa-flask", label: "研究" },
|
||||
{ cls: "fa-book-medical", label: "医学书籍" },
|
||||
{ cls: "fa-user-md", label: "医生" },
|
||||
{ cls: "fa-hospital", label: "医院" },
|
||||
{ cls: "fa-ellipsis-h", label: "其他" },
|
||||
];
|
||||
|
||||
const loadFromProps = () => {
|
||||
editableCategories.value = [...props.categories]
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
.map((c): EditableCategory => ({
|
||||
_key: `cat-${++keyCounter}`,
|
||||
_dirty: false,
|
||||
_deleted: false,
|
||||
_isNew: false,
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
sort_order: c.sort_order ?? 0,
|
||||
icon: c.icon ?? null,
|
||||
study_id: c.study_id,
|
||||
}));
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
editableCategories.value.push({
|
||||
_key: `new-${++keyCounter}`,
|
||||
_dirty: true,
|
||||
_deleted: false,
|
||||
_isNew: true,
|
||||
id: "",
|
||||
name: "",
|
||||
description: null,
|
||||
sort_order: editableCategories.value.length,
|
||||
icon: null,
|
||||
study_id: study.currentStudy?.id ?? null,
|
||||
});
|
||||
// Scroll to bottom
|
||||
setTimeout(() => {
|
||||
const list = document.querySelector(".category-list");
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const selectIconFor = (cat: EditableCategory, icon: string) => {
|
||||
cat.icon = cat.icon === icon ? null : icon;
|
||||
cat._dirty = true;
|
||||
};
|
||||
|
||||
const moveUp = (idx: number) => {
|
||||
if (idx <= 0) return;
|
||||
const arr = editableCategories.value;
|
||||
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
|
||||
arr[idx - 1].sort_order = idx - 1;
|
||||
arr[idx].sort_order = idx;
|
||||
arr[idx - 1]._dirty = true;
|
||||
arr[idx]._dirty = true;
|
||||
};
|
||||
|
||||
const moveDown = (idx: number) => {
|
||||
const arr = editableCategories.value;
|
||||
if (idx >= arr.length - 1) return;
|
||||
[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
|
||||
arr[idx].sort_order = idx;
|
||||
arr[idx + 1].sort_order = idx + 1;
|
||||
arr[idx]._dirty = true;
|
||||
arr[idx + 1]._dirty = true;
|
||||
};
|
||||
|
||||
const removeCategory = (cat: EditableCategory) => {
|
||||
if (!cat._isNew) {
|
||||
const count = (props.faqs || []).filter((f: any) => f.category_id === cat.id).length;
|
||||
if (count > 0) {
|
||||
ElMessage.warning(`该分类下有 ${count} 个问题,无法删除`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const idx = editableCategories.value.findIndex((c) => c._key === cat._key);
|
||||
if (idx >= 0) editableCategories.value.splice(idx, 1);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit("update:modelValue", false);
|
||||
};
|
||||
|
||||
const saveAll = async () => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) {
|
||||
ElMessage.warning(TEXT.common.empty.selectProject);
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
const all = editableCategories.value;
|
||||
|
||||
// Delete removed categories
|
||||
const existingIds = all.filter((c) => !c._isNew && c.id).map((c) => c.id);
|
||||
for (const orig of props.categories) {
|
||||
if (!existingIds.includes(orig.id)) {
|
||||
await deleteFaqCategory(orig.id, studyId);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-index sort_order
|
||||
all.forEach((c, idx) => {
|
||||
c.sort_order = idx;
|
||||
});
|
||||
|
||||
// Create / Update each
|
||||
for (const cat of all) {
|
||||
const payload: Record<string, any> = {
|
||||
study_id: studyId,
|
||||
name: cat.name,
|
||||
description: cat.description || null,
|
||||
sort_order: cat.sort_order,
|
||||
icon: cat.icon || null,
|
||||
};
|
||||
|
||||
if (cat._isNew) {
|
||||
if (!cat.name.trim()) continue;
|
||||
await createFaqCategory(payload);
|
||||
} else if (cat._dirty) {
|
||||
await updateFaqCategory(cat.id, payload);
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success("分类已保存");
|
||||
emit("success");
|
||||
close();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) loadFromProps();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.faq-category-manager-drawer > .el-drawer__header) {
|
||||
margin-bottom: 0 !important;
|
||||
padding: 28px 28px 14px !important;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 50%, #f8fafc 100%) !important;
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
:deep(.faq-category-manager-drawer > .el-drawer__body) {
|
||||
padding: 0 28px 4px;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.header-add-btn {
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 0 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.header-add-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.editor-subtitle {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ==================== Toolbar ==================== */
|
||||
.manager-toolbar .el-button {
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 0 16px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.manager-toolbar .el-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.manager-toolbar .el-button i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* ==================== Category List ==================== */
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.category-row:hover {
|
||||
border-color: #93c5fd;
|
||||
border-left-color: #3b82f6;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.category-row--dirty {
|
||||
border-left-color: #f59e0b !important;
|
||||
background: #fffdf5;
|
||||
}
|
||||
|
||||
.category-row--dirty {
|
||||
border-color: #f59e0b;
|
||||
background: #fffdf5;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
/* ==================== Sort Grip ==================== */
|
||||
.row-grip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sort-num {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
color: #3b82f6;
|
||||
min-width: 22px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: #eff6ff;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.sort-arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.sort-btn:hover:not(:disabled) {
|
||||
border-color: #3b82f6;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sort-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sort-btn:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ==================== Icon Display ==================== */
|
||||
.row-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%);
|
||||
color: #94a3b8;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-shadow: 0 1px 0 #ffffff;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.6), 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.row-icon:hover {
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
color: #6366f1;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 2px 8px rgba(99,102,241,.12);
|
||||
}
|
||||
.row-icon--selected {
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
color: #3730a3;
|
||||
text-shadow: 0 1px 0 rgba(255,255,255,.5);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 2px 10px rgba(59,130,246,.2);
|
||||
}
|
||||
.row-icon--selected:hover {
|
||||
background: linear-gradient(180deg, #c7d2fe 0%, #a5b4fc 100%);
|
||||
}
|
||||
.row-icon-placeholder {
|
||||
font-size: 15px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.icon-popover-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* ==================== Main Content ==================== */
|
||||
.row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.row-name-input :deep(.el-input__wrapper) {
|
||||
border-radius: 10px;
|
||||
border: 2px solid #bfdbfe !important;
|
||||
background: #f8faff !important;
|
||||
box-shadow: none !important;
|
||||
height: 42px;
|
||||
padding: 0 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.row-name-input :deep(.el-input__wrapper:hover) {
|
||||
border-color: #3b82f6 !important;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.row-name-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #1d4ed8 !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15) !important;
|
||||
}
|
||||
|
||||
.row-name-input :deep(.el-input__inner) {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.row-name-input :deep(.el-input__inner::placeholder) {
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==================== Icon Picker ==================== */
|
||||
.mini-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 2px solid #e8eef6;
|
||||
border-radius: 9px;
|
||||
font-size: 15px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
text-shadow: 0 1px 0 #ffffff;
|
||||
}
|
||||
.mini-icon:hover {
|
||||
border-color: #3b82f6;
|
||||
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #1d4ed8;
|
||||
transform: scale(1.12);
|
||||
box-shadow: 0 3px 10px rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
.mini-icon--active {
|
||||
border-color: #4f46e5;
|
||||
background: linear-gradient(180deg, #eef2ff 0%, #ddd6fe 100%);
|
||||
color: #3730a3;
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ==================== Delete ==================== */
|
||||
.row-delete {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: #d1d5db;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.row-delete:hover {
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* ==================== Footer ==================== */
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 28px 20px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.drawer-footer .el-button {
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.drawer-footer .el-button--primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.drawer-footer .el-button--primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +1,42 @@
|
||||
<template>
|
||||
<div class="panel unified-shell">
|
||||
<aside class="panel unified-shell faq-category-panel">
|
||||
<div class="header">
|
||||
<span>{{ TEXT.common.labels.category }}</span>
|
||||
<div v-if="canCreateCategory">
|
||||
<PermissionAction action="faq.category.create">
|
||||
<el-button type="primary" size="small" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newCategory }}</el-button>
|
||||
</PermissionAction>
|
||||
<el-button type="primary" size="small" class="category-create-btn" @click="openManager()">编辑</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-menu :default-active="activeCategory" @select="onSelect" class="menu">
|
||||
<el-menu-item index="">{{ TEXT.common.labels.all }}</el-menu-item>
|
||||
<el-menu-item index="">
|
||||
<span class="cat-icon"><i class="fas fa-th-large"></i></span>
|
||||
<span class="name">{{ TEXT.common.labels.all }}</span>
|
||||
<span class="cat-count">{{ totalFaqCount }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-for="c in sortedCategories" :key="c.id" :index="c.id">
|
||||
<span class="cat-icon">
|
||||
<i v-if="c.icon" :class="['fas', c.icon]"></i>
|
||||
<span v-else>{{ c.name.slice(0, 1) }}</span>
|
||||
</span>
|
||||
<span class="name" :title="c.name">{{ c.name }}</span>
|
||||
<div v-if="canEdit(c) || canDeleteCategory" class="actions">
|
||||
<PermissionAction v-if="canEdit(c)" action="faq.category.update">
|
||||
<el-button type="text" size="small" @click.stop="openForm(c)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
</PermissionAction>
|
||||
<PermissionAction v-if="canDeleteCategory" action="faq.category.delete">
|
||||
<el-button type="text" size="small" class="danger" @click.stop="onDelete(c)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
</PermissionAction>
|
||||
</div>
|
||||
<span class="cat-count">{{ categoryCounts[c.id] || 0 }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<FaqCategoryForm v-model="showForm" :category="editing" :is-admin="isAdmin" @success="emit('refresh')" />
|
||||
</div>
|
||||
<FaqCategoryManager v-model="showForm" :categories="props.categories" :faqs="props.faqs" @success="emit('refresh')" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import FaqCategoryForm from "./FaqCategoryForm.vue";
|
||||
import FaqCategoryManager from "./FaqCategoryManager.vue";
|
||||
import type { FaqCategory } from "../api/faqs";
|
||||
import { deleteFaqCategory } from "../api/faqs";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import PermissionAction from "./PermissionAction.vue";
|
||||
import { usePermission } from "../utils/permission";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
const props = defineProps<{
|
||||
categories: FaqCategory[];
|
||||
faqs?: any[];
|
||||
modelValue: string;
|
||||
}>();
|
||||
const emit = defineEmits(["update:modelValue", "refresh"]);
|
||||
@@ -48,52 +45,43 @@ const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
|
||||
const showForm = ref(false);
|
||||
const editing = ref<FaqCategory | null>(null);
|
||||
|
||||
const { can } = usePermission();
|
||||
const isAdmin = computed(() => !!auth.user?.is_admin);
|
||||
const canCreateCategory = computed(() => can("faq.category.create"));
|
||||
const canUpdateCategory = computed(() => can("faq.category.update"));
|
||||
const canDeleteCategory = computed(() => can("faq.category.delete"));
|
||||
|
||||
const sortedCategories = computed(() =>
|
||||
[...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
);
|
||||
|
||||
const activeCategory = computed(() => props.modelValue || "");
|
||||
|
||||
const canEdit = (c: FaqCategory) => {
|
||||
if (!canUpdateCategory.value) return false;
|
||||
if (isAdmin.value) return true;
|
||||
return c.study_id === study.currentStudy?.id;
|
||||
};
|
||||
const categoryCounts = computed(() => {
|
||||
const map: Record<string, number> = {};
|
||||
(props.faqs || []).forEach((f: any) => {
|
||||
if (f.category_id) {
|
||||
map[f.category_id] = (map[f.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
const totalFaqCount = computed(() => (props.faqs || []).length);
|
||||
|
||||
const onSelect = (id: string) => {
|
||||
if (id && id === activeCategory.value) {
|
||||
openManager();
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", id);
|
||||
};
|
||||
|
||||
const openForm = (cat?: FaqCategory) => {
|
||||
editing.value = cat || null;
|
||||
showForm.value = true;
|
||||
};
|
||||
const activeIcons = (excludeId?: string) =>
|
||||
sortedCategories.value
|
||||
.filter((c) => c.id !== excludeId && c.icon)
|
||||
.map((c) => c.icon as string);
|
||||
|
||||
const onDelete = async (cat: FaqCategory) => {
|
||||
if (!canDeleteCategory.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm(
|
||||
TEXT.modules.knowledgeMedicalConsult.confirmDeleteCategory.replace("{name}", cat.name),
|
||||
TEXT.common.labels.tips
|
||||
).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteFaqCategory(cat.id, study.currentStudy?.id);
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
emit("refresh");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
|
||||
}
|
||||
const openManager = () => {
|
||||
showForm.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -103,26 +91,79 @@ const onDelete = async (cat: FaqCategory) => {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
padding: 24px 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
gap: 10px;
|
||||
padding: 0 8px 16px;
|
||||
border-bottom: 1px solid #edf2f8;
|
||||
color: #8b98aa;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.menu {
|
||||
max-height: calc(100vh - 240px);
|
||||
overflow: auto;
|
||||
border-right: 0;
|
||||
padding: 14px 0 0;
|
||||
background: transparent;
|
||||
}
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
.menu :deep(.el-menu-item) {
|
||||
height: 44px;
|
||||
margin: 4px 0;
|
||||
padding: 0 10px !important;
|
||||
border-radius: 10px;
|
||||
color: #5f6f7a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 44px;
|
||||
}
|
||||
.menu :deep(.el-menu-item:hover) {
|
||||
background: rgba(31, 95, 184, 0.06);
|
||||
color: #1f5fb8;
|
||||
}
|
||||
.menu :deep(.el-menu-item.is-active) {
|
||||
background: #e7f4ff;
|
||||
color: #1f5fb8;
|
||||
}
|
||||
.cat-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 10px;
|
||||
border-radius: 7px;
|
||||
background: rgba(95, 111, 122, 0.08);
|
||||
color: #697982;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.cat-icon i {
|
||||
font-size: 13px;
|
||||
}
|
||||
.menu :deep(.el-menu-item.is-active) .cat-icon {
|
||||
background: #d7ebff;
|
||||
color: #1f5fb8;
|
||||
}
|
||||
.category-create-btn {
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #415a77;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 18px rgba(65, 90, 119, 0.16);
|
||||
}
|
||||
.category-create-btn:hover,
|
||||
.category-create-btn:focus {
|
||||
background: #344960;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
@@ -131,7 +172,24 @@ const onDelete = async (cat: FaqCategory) => {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.danger {
|
||||
color: #f56c6c;
|
||||
|
||||
.cat-count {
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(95, 111, 122, 0.08);
|
||||
color: #697982;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu :deep(.el-menu-item.is-active) .cat-count {
|
||||
background: #d7ebff;
|
||||
color: #1f5fb8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
<el-form-item :label="TEXT.common.fields.question" prop="question">
|
||||
<el-input v-model="form.question" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.enabled">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -62,7 +59,6 @@ const form = reactive({
|
||||
study_id: "",
|
||||
category_id: "",
|
||||
question: "",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
@@ -76,7 +72,6 @@ const reset = () => {
|
||||
form.study_id = study.currentStudy?.id || "";
|
||||
form.category_id = "";
|
||||
form.question = "";
|
||||
form.is_active = true;
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -86,7 +81,6 @@ watch(
|
||||
form.study_id = val.study_id || "";
|
||||
form.category_id = val.category_id;
|
||||
form.question = val.question;
|
||||
form.is_active = val.is_active;
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
@@ -105,7 +99,6 @@ const onSubmit = async () => {
|
||||
study_id: study.currentStudy?.id || null,
|
||||
category_id: form.category_id,
|
||||
question: form.question,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
if (isEdit.value && props.item) {
|
||||
await updateFaqItem(props.item.id, payload);
|
||||
|
||||
@@ -1,59 +1,61 @@
|
||||
<template>
|
||||
<el-table :data="items" v-loading="loading" style="width: 100%" class="faq-table" @row-click="onRowClick" table-layout="fixed">
|
||||
<div class="faq-list-card">
|
||||
<el-table
|
||||
:data="items"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="faq-table"
|
||||
:row-class-name="rowClassName"
|
||||
@row-click="onRowClick"
|
||||
table-layout="fixed"
|
||||
>
|
||||
<el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<el-link type="primary" :underline="false" class="question-link">
|
||||
{{ scope.row.question }}
|
||||
</el-link>
|
||||
<div class="question-cell">
|
||||
<span class="question-icon">{{ questionInitial(scope.row.question) }}</span>
|
||||
<el-link type="primary" :underline="false" class="question-link">
|
||||
{{ scope.row.question }}
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category_id" :label="TEXT.common.labels.category" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ categoryMap[scope.row.category_id] || scope.row.category_id }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" :label="TEXT.common.fields.enabled">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.is_active ? 'success' : 'info'">
|
||||
{{ scope.row.is_active ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}
|
||||
</el-tag>
|
||||
<span class="category-pill">{{ categoryLabel(scope.row.category_id) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" show-overflow-tooltip>
|
||||
<template #default="scope">{{ displayDateTime(scope.row.updated_at) }}</template>
|
||||
<template #default="scope">
|
||||
<span class="time-text">
|
||||
<span class="time-date">{{ splitDateTime(scope.row.updated_at).date }}</span>
|
||||
<span class="time-clock">{{ splitDateTime(scope.row.updated_at).time }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTagType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
<el-tag :type="statusTagType(scope.row.status)" size="small" effect="light" round>{{ statusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" width="140">
|
||||
<el-table-column v-if="canDelete" :label="TEXT.common.labels.actions">
|
||||
<template #default="scope">
|
||||
<el-button v-if="canDelete" type="text" size="small" class="danger" @click.stop="remove(scope.row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
<PermissionAction v-if="canUpdate" action="faq.update">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click.stop="toggle(scope.row)"
|
||||
:style="{ color: scope.row.is_active ? '#f56c6c' : '#67c23a' }"
|
||||
>
|
||||
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canDelete" link type="danger" size="small" class="danger" @click.stop="remove(scope.row)">
|
||||
删除
|
||||
</el-button>
|
||||
</PermissionAction>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { deleteFaqItem, updateFaqItem } from "../api/faqs";
|
||||
import { deleteFaqItem } from "../api/faqs";
|
||||
import type { FaqItem, FaqCategory } from "../api/faqs";
|
||||
import PermissionAction from "./PermissionAction.vue";
|
||||
import { displayDateTime, displayEnum } from "../utils/display";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
@@ -75,12 +77,27 @@ const categoryMap = computed(() =>
|
||||
}, {})
|
||||
);
|
||||
|
||||
const categoryLabel = (categoryId: string) => {
|
||||
return categoryMap.value[categoryId] || categoryId || "--";
|
||||
};
|
||||
|
||||
const questionInitial = (question?: string) => {
|
||||
return String(question || "?").trim().slice(0, 1) || "?";
|
||||
};
|
||||
|
||||
const splitDateTime = (value?: string | number | Date | null) => {
|
||||
const displayValue = displayDateTime(value);
|
||||
const [date, time] = displayValue.split(" ");
|
||||
return { date, time: time || "" };
|
||||
};
|
||||
|
||||
const rowClassName = () => "faq-row";
|
||||
|
||||
const view = (row: FaqItem) => {
|
||||
router.push(`/knowledge/medical-consult/${row.id}`);
|
||||
};
|
||||
|
||||
const onRowClick = (row: FaqItem, column: any, event: MouseEvent) => {
|
||||
if (column?.property !== "question") return;
|
||||
const onRowClick = (row: FaqItem) => {
|
||||
view(row);
|
||||
};
|
||||
|
||||
@@ -94,26 +111,6 @@ const statusTagType = (status?: string) => {
|
||||
return "info";
|
||||
};
|
||||
|
||||
const toggle = async (row: FaqItem) => {
|
||||
if (!props.canUpdate) {
|
||||
ElMessage.warning("权限不足");
|
||||
return;
|
||||
}
|
||||
const target = !row.is_active;
|
||||
const ok = await ElMessageBox.confirm(
|
||||
TEXT.modules.knowledgeMedicalConsult.confirmToggleItem.replace("{action}", target ? TEXT.common.actions.enable : TEXT.common.actions.disable),
|
||||
TEXT.common.labels.tips
|
||||
).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await updateFaqItem(row.id, { study_id: row.study_id, is_active: target });
|
||||
ElMessage.success(TEXT.common.messages.updateSuccess);
|
||||
emit("refresh");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (row: FaqItem) => {
|
||||
if (!props.canDelete) {
|
||||
ElMessage.warning("权限不足");
|
||||
@@ -135,10 +132,125 @@ const remove = async (row: FaqItem) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.faq-list-card {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.faq-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.faq-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
color: #8a98aa;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.faq-table :deep(.el-table__body-wrapper) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.faq-table :deep(.el-table__body) {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 10px;
|
||||
}
|
||||
|
||||
.faq-table :deep(.faq-row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.faq-table :deep(.faq-row td.el-table__cell) {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e8eef6;
|
||||
background: #ffffff;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
.faq-table :deep(.faq-row:hover td.el-table__cell) {
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.faq-table :deep(col:nth-child(1)) { width: 43%; }
|
||||
.faq-table :deep(col:nth-child(2)) { width: 14%; }
|
||||
.faq-table :deep(col:nth-child(3)) { width: 14%; }
|
||||
.faq-table :deep(col:nth-child(4)) { width: 14%; }
|
||||
.faq-table :deep(col:nth-child(5)) { width: 15%; }
|
||||
|
||||
|
||||
|
||||
/* 紧凑操作按钮 */
|
||||
.faq-row .cell-actions { display: flex; gap: 4px; white-space: nowrap; }
|
||||
.faq-row .cell-actions .el-button { font-size: 12px; padding: 0; height: auto; }
|
||||
.faq-row .cell-actions .el-button + .el-button { margin-left: 0; }
|
||||
.danger { color: #f56c6c !important; }
|
||||
|
||||
.question-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.question-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: #f3f7fb;
|
||||
color: #5e6f7b;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.question-link {
|
||||
min-width: 0;
|
||||
color: #26323b;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
background: #eaf4ff;
|
||||
color: #2c73d2;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: #8794a6;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-clock {
|
||||
color: #9aa6b6;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
+222
-29
@@ -1,27 +1,45 @@
|
||||
<template>
|
||||
<div class="page ctms-page-shell page--flush">
|
||||
<el-row :gutter="12">
|
||||
<el-col v-if="canReadCategories" :span="5">
|
||||
<div class="page ctms-page-shell page--flush medical-consult-page">
|
||||
<div class="page-bg-dots"></div>
|
||||
<section class="faq-hero">
|
||||
<h1><i class="fas fa-book-medical hero-title-icon"></i>项目知识库</h1>
|
||||
<div class="hero-tools">
|
||||
<el-autocomplete
|
||||
v-model="keyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
clearable
|
||||
class="hero-search"
|
||||
:fetch-suggestions="fetchSuggestions"
|
||||
:trigger-on-focus="false"
|
||||
highlight-first-item
|
||||
@select="onSuggestionSelect"
|
||||
@keyup.enter="loadFaqs"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<div class="spacer" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-row :gutter="0" class="faq-workspace">
|
||||
<el-col v-if="canReadCategories" :span="5" class="faq-sidebar-col">
|
||||
<FaqCategoryPanel
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
:faqs="faqs"
|
||||
@refresh="loadCategories"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="canReadCategories ? 19 : 24">
|
||||
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
|
||||
<div class="faq-main unified-shell">
|
||||
<div class="filters unified-action-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
:placeholder="TEXT.common.placeholders.keyword"
|
||||
clearable
|
||||
@keyup.enter="loadFaqs"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<el-switch v-model="onlyActive" :active-text="TEXT.common.labels.activeOnly" @change="loadFaqs" />
|
||||
<div class="spacer" />
|
||||
<div class="list-toolbar">
|
||||
<PermissionAction action="faq.create">
|
||||
<el-button type="primary" @click="openForm()">{{ TEXT.modules.knowledgeMedicalConsult.newItem }}</el-button>
|
||||
<el-button type="primary" class="new-consult-btn" @click="openForm()">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
新建
|
||||
</el-button>
|
||||
</PermissionAction>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +74,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Plus, Search } from "@element-plus/icons-vue";
|
||||
import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
|
||||
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
|
||||
import FaqList from "../components/FaqList.vue";
|
||||
@@ -76,7 +95,7 @@ const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const keyword = ref("");
|
||||
const onlyActive = ref(true);
|
||||
|
||||
const activeCategory = ref("");
|
||||
const showForm = ref(false);
|
||||
const editing = ref<any | null>(null);
|
||||
@@ -114,7 +133,6 @@ const loadFaqs = async () => {
|
||||
};
|
||||
if (activeCategory.value) params.category_id = activeCategory.value;
|
||||
if (keyword.value) params.keyword = keyword.value;
|
||||
if (onlyActive.value) params.is_active = true;
|
||||
const { data } = await fetchFaqItems(params);
|
||||
if (Array.isArray(data)) {
|
||||
faqs.value = data;
|
||||
@@ -130,6 +148,30 @@ const loadFaqs = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSuggestions = async (queryString: string, callback: (results: Array<{ value: string }>) => void) => {
|
||||
if (!queryString || !study.currentStudy) {
|
||||
callback([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
study_id: study.currentStudy.id,
|
||||
keyword: queryString,
|
||||
limit: 8,
|
||||
};
|
||||
const { data } = await fetchFaqItems(params);
|
||||
const items = Array.isArray(data) ? data : data.items || [];
|
||||
callback(items.slice(0, 8).map((item: any) => ({ value: item.question || item.title || "" })));
|
||||
} catch {
|
||||
callback([]);
|
||||
}
|
||||
};
|
||||
|
||||
const onSuggestionSelect = (item: { value: string }) => {
|
||||
keyword.value = item.value;
|
||||
loadFaqs();
|
||||
};
|
||||
|
||||
const onPageChange = (p: number) => {
|
||||
page.value = p;
|
||||
loadFaqs();
|
||||
@@ -162,26 +204,177 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
min-height: calc(100vh - 64px);
|
||||
background: linear-gradient(180deg, #f0f4ff 0%, #ffffff 30%, #f8fafc 100%);
|
||||
}
|
||||
.faq-main {
|
||||
background: #fff;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
|
||||
.page-bg-dots {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
background-image: radial-gradient(#1e40af 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
.faq-hero {
|
||||
position: relative;
|
||||
padding: 28px 24px 36px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #e8eef6;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 0%, rgba(59, 130, 246, 0.06) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #ffffff 0%, #fafcff 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.faq-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 400px;
|
||||
height: 200px;
|
||||
background: radial-gradient(ellipse, rgba(59, 130, 246, 0.08) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
|
||||
.faq-hero h1 {
|
||||
margin: 0 0 24px;
|
||||
color: #0f172a;
|
||||
font-size: 32px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-title-icon {
|
||||
font-size: 28px;
|
||||
color: #3b82f6;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
margin: 8px 0 28px;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hero-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.hero-search {
|
||||
width: min(620px, 54vw);
|
||||
}
|
||||
.hero-search :deep(.el-input__wrapper) {
|
||||
height: 54px;
|
||||
padding: 0 20px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 14px 34px rgba(15, 35, 69, 0.08), 0 0 0 1px #e6edf6 inset;
|
||||
}
|
||||
.hero-search :deep(.el-input__inner) {
|
||||
color: #1f2d3d;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero-search :deep(.el-input__prefix) {
|
||||
color: #9aa9bb;
|
||||
font-size: 20px;
|
||||
}
|
||||
.new-consult-btn {
|
||||
flex-shrink: 0;
|
||||
height: 46px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.new-consult-btn:hover,
|
||||
.new-consult-btn:focus {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
.faq-workspace {
|
||||
min-height: calc(100vh - 252px);
|
||||
}
|
||||
.faq-sidebar-col {
|
||||
border-right: 1px solid #edf2f8;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.faq-content-col {
|
||||
min-width: 0;
|
||||
}
|
||||
.faq-main {
|
||||
min-height: calc(100vh - 252px);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 24px 28px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.list-title strong {
|
||||
min-width: 30px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #e8f2ff;
|
||||
color: #1f5fb8;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.list-meta {
|
||||
color: #7d8da3;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pagination-wrap {
|
||||
padding: 10px 16px 0;
|
||||
padding: 16px 0 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.hero-tools {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-search {
|
||||
width: 100%;
|
||||
}
|
||||
.faq-sidebar-col,
|
||||
.faq-content-col {
|
||||
max-width: 100%;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
.faq-main {
|
||||
padding: 18px 16px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -235,7 +235,7 @@ const loadReplies = async () => {
|
||||
const id = route.params.itemId as string;
|
||||
repliesLoading.value = true;
|
||||
try {
|
||||
const { data } = await fetchFaqReplies(id);
|
||||
const { data } = await fetchFaqReplies(id, item.value?.study_id || study.currentStudy?.id);
|
||||
replies.value = Array.isArray(data) ? data : data.items || [];
|
||||
await loadReplyAttachments();
|
||||
} catch (e: any) {
|
||||
@@ -249,7 +249,7 @@ const loadData = async () => {
|
||||
const id = route.params.itemId as string;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data: faqData } = await fetchFaqItem(id);
|
||||
const { data: faqData } = await fetchFaqItem(id, study.currentStudy?.id);
|
||||
item.value = faqData;
|
||||
const questionTitle = String(faqData.question || "").trim();
|
||||
study.setViewContext({
|
||||
@@ -288,7 +288,7 @@ const submitReply = async () => {
|
||||
if (!item.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const { data } = await createFaqReply(item.value.id, {
|
||||
const { data } = await createFaqReply(item.value.id, item.value.study_id, {
|
||||
content: replyContent.value,
|
||||
quote_reply_id: quoteReply.value?.id || null,
|
||||
});
|
||||
@@ -324,7 +324,7 @@ const removeReply = async (reply: any) => {
|
||||
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteFaqReply(item.value.id, reply.id);
|
||||
await deleteFaqReply(item.value.id, reply.id, item.value.study_id);
|
||||
ElMessage.success(TEXT.common.messages.deleteSuccess);
|
||||
loadReplies();
|
||||
} catch (e: any) {
|
||||
@@ -344,7 +344,7 @@ const toggleBest = async (reply: any) => {
|
||||
const ok = await ElMessageBox.confirm(TEXT.modules.knowledgeMedicalConsult.confirmBestReply, TEXT.common.labels.tips).catch(() => null);
|
||||
if (!ok) return;
|
||||
}
|
||||
const { data } = await setFaqBestReply(item.value.id, { best_reply_id: target });
|
||||
const { data } = await setFaqBestReply(item.value.id, item.value.study_id, { best_reply_id: target });
|
||||
item.value = data;
|
||||
ElMessage.success(target ? TEXT.modules.knowledgeMedicalConsult.bestReplySet : TEXT.modules.knowledgeMedicalConsult.bestReplyCleared);
|
||||
} catch (e: any) {
|
||||
@@ -359,7 +359,7 @@ const confirmResolved = async () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await setFaqStatus(item.value.id, { status: "RESOLVED" });
|
||||
const { data } = await setFaqStatus(item.value.id, item.value.study_id, { status: "RESOLVED" });
|
||||
item.value = data;
|
||||
ElMessage.success(TEXT.modules.knowledgeMedicalConsult.resolvedSuccess);
|
||||
} catch (e: any) {
|
||||
@@ -413,6 +413,14 @@ onMounted(() => {
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-card.unified-shell) {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.page :deep(.unified-shell) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
@@ -548,7 +556,7 @@ onMounted(() => {
|
||||
.question-text {
|
||||
margin: 6px 0 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f5f7ff;
|
||||
border: 1px solid #e0e7ff;
|
||||
color: #1f2a44;
|
||||
|
||||
@@ -37,6 +37,16 @@ describe("FAQ project permissions", () => {
|
||||
expect(source).not.toContain("row.created_by === auth.user?.id");
|
||||
});
|
||||
|
||||
it("opens FAQ detail from any row cell while preserving action button click isolation", () => {
|
||||
const source = read("../components/FaqList.vue");
|
||||
|
||||
expect(source).toContain('@row-click="onRowClick"');
|
||||
expect(source).toContain("const onRowClick = (row: FaqItem) =>");
|
||||
expect(source).not.toContain('column?.property !== "question"');
|
||||
expect(source).toContain('@click.stop="remove(scope.row)"');
|
||||
expect(source).toContain('@click.stop="toggle(scope.row)"');
|
||||
});
|
||||
|
||||
it("splits FAQ category create update and delete controls by backend operation permissions", () => {
|
||||
const source = read("../components/FaqCategoryPanel.vue");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user