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
+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);
+164 -52
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">
<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;
}