feat(faq): refine category icons and project faq flows

This commit is contained in:
Cheng Zhou
2026-06-08 10:59:32 +08:00
parent f6e5a4c744
commit e5ed18c3cd
18 changed files with 1375 additions and 214 deletions
+9 -2
View File
@@ -42,6 +42,10 @@ async def create_category(
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
if payload.icon:
dup = await category_crud.get_category_by_icon(db, payload.study_id, payload.icon)
if dup:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
category = await category_crud.create_category(db, payload)
await audit_crud.log_action(
db,
@@ -65,13 +69,12 @@ async def create_category(
)
async def list_categories(
study_id: uuid.UUID | None = None,
is_active: bool | None = True,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[CategoryRead]:
if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active)
categories = await category_crud.list_categories(db, study_id, include_global=False)
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
@@ -102,6 +105,10 @@ async def update_category(
existing = await category_crud.get_category_by_name(db, target_study_id, target_name)
if existing and existing.id != category.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
if payload.icon:
dup = await category_crud.get_category_by_icon(db, target_study_id, payload.icon)
if dup and dup.id != category.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
updated = await category_crud.update_category(db, category, payload)
-21
View File
@@ -90,7 +90,6 @@ async def list_faqs(
study_id: uuid.UUID | None = None,
category_id: uuid.UUID | None = None,
keyword: str | None = None,
is_active: bool | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FaqRead]:
@@ -111,17 +110,12 @@ async def list_faqs(
member = await member_crud.get_member(db, study_id, current_user.id)
if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
if is_active is False:
allowed = await role_has_api_permission(db, study_id, member.role_in_study, "faq:update", check_prerequisites=False)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
items = await faq_crud.list_items(
db,
study_id=study_id,
category_id=category_id,
keyword=keyword,
is_active=is_active,
study_scope="project",
)
@@ -131,10 +125,6 @@ async def list_faqs(
role = await _get_member_role(it.study_id)
if not role:
continue
if not it.is_active and not _is_system_admin(current_user):
allowed = await role_has_api_permission(db, it.study_id, role, "faq:update", check_prerequisites=False)
if not allowed:
continue
visible.append(FaqRead.model_validate(it))
return paginate(visible, total=len(visible))
@@ -156,13 +146,6 @@ async def get_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.is_active and not is_system_admin(current_user):
member = await member_crud.get_member(db, item.study_id, current_user.id)
if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_api_permission(db, item.study_id, member.role_in_study, "faq:update", check_prerequisites=False)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FaqRead.model_validate(item)
@@ -185,13 +168,9 @@ async def update_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
old_active = item.is_active
updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
detail = "FAQ updated"
if payload.is_active is not None and payload.is_active != old_active:
action = "FAQ_STATUS_CHANGE"
detail = "FAQ disabled" if not payload.is_active else "FAQ enabled"
await audit_crud.log_action(
db,
study_id=item.study_id,
+17 -4
View File
@@ -14,7 +14,7 @@ async def create_category(db: AsyncSession, category_in: CategoryCreate) -> FaqC
name=category_in.name,
description=category_in.description,
sort_order=category_in.sort_order,
is_active=category_in.is_active,
icon=category_in.icon,
)
db.add(category)
await db.commit()
@@ -26,15 +26,12 @@ async def list_categories(
db: AsyncSession,
study_id: uuid.UUID | None,
include_global: bool = True,
is_active: bool | None = True,
) -> Sequence[FaqCategory]:
stmt = select(FaqCategory)
if include_global:
stmt = stmt.where((FaqCategory.study_id == study_id) | (FaqCategory.study_id.is_(None)))
else:
stmt = stmt.where(FaqCategory.study_id == study_id)
if is_active is not None:
stmt = stmt.where(FaqCategory.is_active == is_active)
stmt = stmt.order_by(FaqCategory.sort_order, FaqCategory.name)
result = await db.execute(stmt)
return result.scalars().all()
@@ -59,6 +56,22 @@ async def get_category_by_name(
return result.scalar_one_or_none()
async def get_category_by_icon(
db: AsyncSession,
study_id: uuid.UUID | None,
icon: str,
) -> FaqCategory | None:
if not icon:
return None
stmt = select(FaqCategory).where(FaqCategory.icon == icon)
if study_id is None:
stmt = stmt.where(FaqCategory.study_id.is_(None))
else:
stmt = stmt.where(FaqCategory.study_id == study_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def update_category(db: AsyncSession, category: FaqCategory, category_in: CategoryUpdate) -> FaqCategory:
update_data = category_in.model_dump(exclude_unset=True)
if update_data:
-4
View File
@@ -26,7 +26,6 @@ async def create_item(db: AsyncSession, item_in: FaqCreate, *, created_by: uuid.
answer=item_in.answer or "",
status="PENDING",
version=1,
is_active=True,
created_by=created_by,
)
db.add(item)
@@ -46,7 +45,6 @@ async def list_items(
study_id: uuid.UUID | None,
category_id: uuid.UUID | None = None,
keyword: str | None = None,
is_active: bool | None = True,
study_scope: str | None = "project",
) -> Sequence[FaqItem]:
stmt = select(FaqItem).join(FaqCategory, FaqCategory.id == FaqItem.category_id)
@@ -69,8 +67,6 @@ async def list_items(
FaqItem.keywords.ilike(like_pattern),
)
)
if is_active is not None:
stmt = stmt.where(FaqItem.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
+2 -2
View File
@@ -4,7 +4,7 @@ from typing import Optional
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -20,7 +20,7 @@ class FaqCategory(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
icon: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
-1
View File
@@ -26,7 +26,6 @@ class FaqItem(Base):
answer: Mapped[str] = mapped_column(Text, nullable=False)
keywords: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
+3 -5
View File
@@ -10,7 +10,7 @@ class CategoryCreate(BaseModel):
name: str
description: Optional[str] = None
sort_order: int = 0
is_active: bool = True
icon: Optional[str] = None
class CategoryUpdate(BaseModel):
@@ -18,7 +18,7 @@ class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
icon: Optional[str] = None
class CategoryRead(BaseModel):
@@ -27,7 +27,7 @@ class CategoryRead(BaseModel):
name: str
description: Optional[str]
sort_order: int
is_active: bool
icon: Optional[str]
created_at: datetime
updated_at: datetime
@@ -44,7 +44,6 @@ class FaqCreate(BaseModel):
class FaqUpdate(BaseModel):
question: Optional[str] = None
answer: Optional[str] = None
is_active: Optional[bool] = None
class FaqStatusUpdate(BaseModel):
@@ -94,7 +93,6 @@ class FaqRead(BaseModel):
answer: str
keywords: Optional[str]
version: int
is_active: bool
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
+1
View File
@@ -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>
+32
View File
@@ -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
View File
@@ -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));
+130 -10
View File
@@ -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>
+115 -57
View File
@@ -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>
-7
View File
@@ -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);
+160 -48
View File
@@ -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">
<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 }}
<div class="cell-actions">
<el-button v-if="canDelete" link type="danger" size="small" class="danger" @click.stop="remove(scope.row)">
删除
</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 }}
</el-button>
</PermissionAction>
</div>
</template>
</el-table-column>
</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;
}
+224 -31
View File
@@ -1,27 +1,45 @@
<template>
<div class="page ctms-page-shell page--flush">
<el-row :gutter="12">
<el-col v-if="canReadCategories" :span="5">
<FaqCategoryPanel
v-model="activeCategory"
:categories="categories"
@refresh="loadCategories"
/>
</el-col>
<el-col :span="canReadCategories ? 19 : 24">
<div class="faq-main unified-shell">
<div class="filters unified-action-bar">
<el-input
<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"
style="width: 260px"
/>
<el-switch v-model="onlyActive" :active-text="TEXT.common.labels.activeOnly" @change="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" class="faq-content-col">
<div class="faq-main unified-shell">
<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>
+15 -7
View File
@@ -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");