Files
Enterprise-Knowledge-Base/frontend/src/views/knowledge/components/FAQEntryManager.vue
T

5770 lines
152 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="faq-manager">
<div class="faq-content">
<!-- Header -->
<div class="faq-header">
<div class="faq-header-title">
<div class="faq-title-row">
<h2 class="faq-breadcrumb">
<button type="button" class="breadcrumb-link" @click="handleNavigateToKbList">
{{ $t('menu.knowledgeBase') }}
</button>
<t-icon name="chevron-right" class="breadcrumb-separator" />
<t-dropdown
v-if="knowledgeDropdownOptions.length"
:options="knowledgeDropdownOptions"
trigger="click"
placement="bottom-left"
@click="handleKnowledgeDropdownSelect"
>
<button
type="button"
class="breadcrumb-link dropdown"
:disabled="!props.kbId"
@click.stop="handleNavigateToCurrentKB"
>
<span>{{ kbInfo?.name || '--' }}</span>
<t-icon name="chevron-down" />
</button>
</t-dropdown>
<button
v-else
type="button"
class="breadcrumb-link"
:disabled="!props.kbId"
@click="handleNavigateToCurrentKB"
>
{{ kbInfo?.name || '--' }}
</button>
<t-icon name="chevron-right" class="breadcrumb-separator" />
<span class="breadcrumb-current">{{ $t('knowledgeEditor.faq.title') }}</span>
</h2>
<t-tooltip :content="$t('knowledgeBase.settings')" placement="top">
<button
type="button"
class="kb-settings-button"
:disabled="!props.kbId"
@click="handleOpenKBSettings"
>
<t-icon name="setting" size="16px" />
</button>
</t-tooltip>
</div>
<p class="faq-subtitle">{{ $t('knowledgeEditor.faq.subtitle') }}</p>
</div>
</div>
<!-- 导入进度条(显示在列表页面顶部) -->
<div v-if="importState.taskId && importState.taskStatus" class="faq-import-progress-bar">
<div class="progress-bar-content">
<div class="progress-bar-header">
<div class="progress-left">
<t-icon
:name="importState.taskStatus.status === 'running' ? 'loading' :
importState.taskStatus.status === 'success' ? 'check-circle' :
importState.taskStatus.status === 'failed' ? 'error-circle' : 'time'"
size="18px"
class="progress-icon"
:class="{
'icon-loading': importState.taskStatus.status === 'running',
'icon-success': importState.taskStatus.status === 'success',
'icon-error': importState.taskStatus.status === 'failed'
}"
/>
<span class="progress-title">
{{ importState.taskStatus.status === 'running' ? '导入中...' :
importState.taskStatus.status === 'success' ? '导入完成' :
importState.taskStatus.status === 'failed' ? '导入失败' : '等待中...' }}
</span>
</div>
<div class="progress-right">
<span class="progress-count">
{{ importState.taskStatus.processed }}/{{ importState.taskStatus.total }} 条
</span>
<t-button
v-if="importState.taskStatus.status === 'success' || importState.taskStatus.status === 'failed'"
variant="text"
theme="default"
size="small"
class="progress-close-btn"
@click="handleCloseProgress"
>
<t-icon name="close" size="14px" />
</t-button>
</div>
</div>
<t-progress
:percentage="importState.taskStatus.progress"
:status="importState.taskStatus.status === 'failed' ? 'error' :
importState.taskStatus.status === 'success' ? 'success' : 'active'"
:label="false"
class="progress-bar"
/>
<p v-if="importState.taskStatus.error" class="progress-error">
{{ importState.taskStatus.error }}
</p>
</div>
</div>
<div class="faq-main">
<aside class="faq-tag-panel">
<div class="sidebar-header">
<div class="sidebar-title">
<span>{{ $t('knowledgeBase.faqCategoryTitle') }}</span>
<span class="sidebar-count">({{ sidebarCategoryCount }})</span>
</div>
<div class="sidebar-actions">
<t-button
size="small"
variant="text"
class="create-tag-btn"
:aria-label="$t('knowledgeBase.tagCreateAction')"
:title="$t('knowledgeBase.tagCreateAction')"
@click="startCreateTag"
>
<span class="create-tag-plus" aria-hidden="true">+</span>
</t-button>
</div>
</div>
<div class="tag-search-bar">
<t-input
v-model.trim="tagSearchQuery"
size="small"
:placeholder="$t('knowledgeBase.tagSearchPlaceholder')"
clearable
>
<template #prefix-icon>
<t-icon name="search" size="14px" />
</template>
</t-input>
</div>
<t-loading :loading="tagLoading" size="small">
<div ref="tagListRef" class="faq-tag-list" @scroll="handleTagListScroll">
<div
class="faq-tag-item"
:class="{ active: selectedTagId === UNTAGGED_FILTER }"
@click="handleUntaggedClick"
>
<div class="faq-tag-left">
<t-icon name="folder" size="18px" />
<span>{{ $t('knowledgeBase.untagged') }}</span>
</div>
<div class="faq-tag-right">
<span class="faq-tag-count">{{ untaggedFAQCount }}</span>
<!-- Placeholder for alignment with other tags that have more menu -->
<div class="tag-more-placeholder"></div>
</div>
</div>
<div v-if="creatingTag" class="faq-tag-item tag-editing" @click.stop>
<div class="faq-tag-left">
<t-icon name="folder" size="18px" />
<div class="tag-edit-input">
<t-input
ref="newTagInputRef"
v-model="newTagName"
size="small"
:maxlength="40"
:placeholder="$t('knowledgeBase.tagNamePlaceholder')"
@keydown.enter.stop.prevent="submitCreateTag"
@keydown.esc.stop.prevent="cancelCreateTag"
/>
</div>
</div>
<div class="tag-inline-actions">
<t-button
variant="text"
theme="default"
size="small"
class="tag-action-btn confirm"
:loading="creatingTagLoading"
@click.stop="submitCreateTag"
>
<t-icon name="check" size="16px" />
</t-button>
<t-button
variant="text"
theme="default"
size="small"
class="tag-action-btn cancel"
@click.stop="cancelCreateTag"
>
<t-icon name="close" size="16px" />
</t-button>
</div>
</div>
<template v-if="filteredTags.length">
<div
v-for="tag in filteredTags"
:key="tag.id"
class="faq-tag-item"
:class="{ active: selectedTagId === tag.id, editing: editingTagId === tag.id }"
@click="handleTagRowClick(tag.id)"
>
<div class="faq-tag-left">
<t-icon name="folder" size="18px" />
<template v-if="editingTagId === tag.id">
<div class="tag-edit-input" @click.stop>
<t-input
:ref="setEditingTagInputRefByTag(tag.id)"
v-model="editingTagName"
size="small"
:maxlength="40"
@keydown.enter.stop.prevent="submitEditTag"
@keydown.esc.stop.prevent="cancelEditTag"
/>
</div>
</template>
<template v-else>
<span class="tag-name" :title="tag.name">{{ tag.name }}</span>
</template>
</div>
<div class="faq-tag-right">
<span class="faq-tag-count">{{ tag.chunk_count || 0 }}</span>
<div v-if="editingTagId === tag.id" class="tag-inline-actions" @click.stop>
<t-button
variant="text"
theme="default"
size="small"
class="tag-action-btn confirm"
:loading="editingTagSubmitting"
@click.stop="submitEditTag"
>
<t-icon name="check" size="16px" />
</t-button>
<t-button
variant="text"
theme="default"
size="small"
class="tag-action-btn cancel"
@click.stop="cancelEditTag"
>
<t-icon name="close" size="16px" />
</t-button>
</div>
<div v-else class="tag-more" @click.stop>
<t-popup trigger="click" placement="top-right" overlayClassName="tag-more-popup">
<div class="tag-more-btn">
<t-icon name="more" size="14px" />
</div>
<template #content>
<div class="tag-menu">
<div class="tag-menu-item" @click="startEditTag(tag)">
<t-icon class="menu-icon" name="edit" />
<span>{{ $t('knowledgeBase.tagEditAction') }}</span>
</div>
<div class="tag-menu-item danger" @click="confirmDeleteTag(tag)">
<t-icon class="menu-icon" name="delete" />
<span>{{ $t('knowledgeBase.tagDeleteAction') }}</span>
</div>
</div>
</template>
</t-popup>
</div>
</div>
</div>
</template>
<div v-else class="tag-empty-state">
{{ $t('knowledgeBase.tagEmptyResult') }}
</div>
<div v-if="tagLoadingMore" class="tag-loading-more">
<t-loading size="small" />
</div>
</div>
</t-loading>
</aside>
<div class="faq-card-area">
<!-- 搜索栏 -->
<div class="faq-search-bar">
<t-input
v-model.trim="entrySearchKeyword"
:placeholder="$t('knowledgeEditor.faq.searchPlaceholder')"
clearable
@clear="loadEntries()"
@keydown.enter="loadEntries()"
>
<template #prefix-icon>
<t-icon name="search" size="16px" />
</template>
</t-input>
</div>
<!-- Card List Container with Scroll -->
<div ref="scrollContainer" class="faq-scroll-container" @scroll="handleScroll">
<t-loading :loading="loading && entries.length === 0" size="medium">
<!-- Card List -->
<template v-if="entries.length > 0">
<div ref="cardListRef" class="faq-card-list">
<div
v-for="entry in entries"
:key="entry.id"
class="faq-card"
:class="{ 'selected': selectedRowKeys.includes(entry.id) }"
@click="handleCardSelect(entry.id, !selectedRowKeys.includes(entry.id))"
>
<!-- Card Header -->
<div class="faq-card-header">
<div class="faq-header-top">
<div class="faq-question" :title="entry.standard_question">
{{ entry.standard_question }}
</div>
<div class="faq-card-actions">
<t-popup
v-model="entry.showMore"
overlayClassName="faq-card-popup"
trigger="click"
destroy-on-close
placement="bottom-right"
@visible-change="(visible: boolean) => (entry.showMore = visible)"
>
<div class="card-more-btn" @click.stop>
<img class="more-icon" src="@/assets/img/more.png" alt="" />
</div>
<template #content>
<div class="card-menu" @click.stop>
<div class="card-menu-item" @click.stop="handleMenuEdit(entry)">
<t-icon class="menu-icon" name="edit" />
<span>{{ $t('common.edit') }}</span>
</div>
<div class="card-menu-item danger" @click.stop="handleMenuDelete(entry)">
<t-icon class="menu-icon" name="delete" />
<span>{{ $t('common.delete') }}</span>
</div>
</div>
</template>
</t-popup>
</div>
</div>
</div>
<!-- Card Body -->
<div class="faq-card-body">
<!-- Similar Questions Section -->
<div v-if="entry.similar_questions?.length" class="faq-section similar">
<div
class="faq-section-label clickable"
@click.stop="entry.similarCollapsed = !entry.similarCollapsed"
>
<span>{{ $t('knowledgeEditor.faq.similarQuestions') }}</span>
<span class="section-count">
({{ entry.similar_questions.length }})
</span>
<t-icon
:name="entry.similarCollapsed ? 'chevron-right' : 'chevron-down'"
class="collapse-icon"
/>
</div>
<Transition name="slide-down">
<div v-if="!entry.similarCollapsed" class="faq-tags">
<FAQTagTooltip
v-for="question in entry.similar_questions"
:key="question"
:content="question"
type="similar"
placement="top"
>
<t-tag
size="small"
variant="light-outline"
class="question-tag"
>
{{ question }}
</t-tag>
</FAQTagTooltip>
</div>
</Transition>
</div>
<!-- Negative Questions Section -->
<div v-if="entry.negative_questions?.length" class="faq-section negative">
<div
class="faq-section-label clickable"
@click.stop="entry.negativeCollapsed = !entry.negativeCollapsed"
>
<span>{{ $t('knowledgeEditor.faq.negativeQuestions') }}</span>
<span class="section-count">
({{ entry.negative_questions.length }})
</span>
<t-icon
:name="entry.negativeCollapsed ? 'chevron-right' : 'chevron-down'"
class="collapse-icon"
/>
</div>
<Transition name="slide-down">
<div v-if="!entry.negativeCollapsed" class="faq-tags">
<FAQTagTooltip
v-for="question in entry.negative_questions"
:key="question"
:content="question"
type="negative"
placement="top"
>
<t-tag
size="small"
theme="warning"
variant="light-outline"
class="question-tag"
>
{{ question }}
</t-tag>
</FAQTagTooltip>
</div>
</Transition>
</div>
<!-- Answers Section -->
<div class="faq-section answers">
<div
class="faq-section-label clickable"
@click.stop="entry.answersCollapsed = !entry.answersCollapsed"
>
<span>{{ $t('knowledgeEditor.faq.answers') }}</span>
<span v-if="entry.answers?.length" class="section-count">
({{ entry.answers.length }})
</span>
<t-icon
:name="entry.answersCollapsed ? 'chevron-right' : 'chevron-down'"
class="collapse-icon"
/>
</div>
<Transition name="slide-down">
<div v-if="!entry.answersCollapsed" class="faq-tags">
<FAQTagTooltip
v-for="answer in entry.answers"
:key="answer"
:content="answer"
type="answer"
placement="top"
>
<t-tag
size="small"
theme="success"
variant="light-outline"
class="question-tag"
>
{{ answer }}
</t-tag>
</FAQTagTooltip>
</div>
</Transition>
</div>
</div>
<!-- Card Footer -->
<div class="faq-card-footer">
<div class="faq-card-tag" @click.stop>
<template v-if="tagList.length">
<t-dropdown
:options="tagDropdownOptions"
trigger="click"
@click="(data: any) => handleEntryTagChange(entry.id, data.value as string)"
>
<t-tag size="small" variant="light-outline" class="faq-tag-chip">
<span class="tag-text">{{ getTagName(entry.tag_id) || $t('knowledgeBase.untagged') }}</span>
</t-tag>
</t-dropdown>
</template>
<template v-else>
<t-tag size="small" variant="light-outline" class="faq-tag-chip">
<span class="tag-text">{{ getTagName(entry.tag_id) || $t('knowledgeBase.untagged') }}</span>
</t-tag>
</template>
</div>
<div class="faq-card-status" @click.stop>
<!-- 暂时隐藏推荐开关
<t-tooltip
:content="entry.is_recommended ? $t('knowledgeEditor.faq.recommendedEnabled') : $t('knowledgeEditor.faq.recommendedDisabled')"
placement="top"
>
<div class="status-item-compact">
<t-switch
:key="`${entry.id}-recommended-${entry.is_recommended}`"
size="small"
:value="entry.is_recommended"
:loading="!!entryRecommendedLoading[entry.id]"
:disabled="!!entryRecommendedLoading[entry.id]"
@click.stop
@change="(value: boolean) => handleEntryRecommendedChange(entry, value)"
/>
<span class="status-label">{{ $t('knowledgeEditor.faq.recommended') }}</span>
</div>
</t-tooltip>
-->
<t-tooltip
:content="entry.is_enabled ? $t('knowledgeEditor.faq.statusEnabled') : $t('knowledgeEditor.faq.statusDisabled')"
placement="top"
>
<div class="status-item-compact">
<t-switch
:key="`${entry.id}-${entry.is_enabled}`"
size="small"
:value="entry.is_enabled"
:loading="!!entryStatusLoading[entry.id]"
:disabled="!!entryStatusLoading[entry.id]"
@click.stop
@change="(value: boolean) => handleEntryStatusChange(entry, value)"
/>
</div>
</t-tooltip>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div v-if="!loading" class="faq-empty-state">
<div class="empty-content">
<t-icon name="file-add" size="48px" class="empty-icon" />
<div class="empty-text">{{ $t('knowledgeEditor.faq.emptyTitle') }}</div>
<div class="empty-desc">{{ $t('knowledgeEditor.faq.emptyDesc') }}</div>
</div>
</div>
</template>
</t-loading>
<div v-if="loadingMore" class="faq-load-more">
<t-loading size="small" :text="$t('common.loading')" />
</div>
<div v-if="hasMore === false && entries.length > 0" class="faq-no-more">
{{ $t('common.noMoreData') }}
</div>
</div>
</div>
</div>
</div>
<!-- Editor Drawer -->
<t-drawer
v-model:visible="editorVisible"
:header="editorMode === 'create' ? $t('knowledgeEditor.faq.editorCreate') : $t('knowledgeEditor.faq.editorEdit')"
:close-btn="true"
size="520px"
placement="right"
class="faq-editor-drawer"
@close="handleEditorClose"
>
<div class="faq-editor-drawer-content">
<t-form
ref="editorFormRef"
:data="editorForm"
:rules="editorRules"
layout="vertical"
:label-width="0"
class="faq-editor-form"
>
<div class="settings-group">
<!-- 标准问 -->
<div class="setting-row vertical setting-row-primary">
<div class="setting-info">
<label class="required-label">
{{ $t('knowledgeEditor.faq.standardQuestion') }}
<span class="required-mark">*</span>
</label>
<p class="desc">{{ $t('knowledgeEditor.faq.standardQuestionDesc') }}</p>
</div>
<div class="setting-control">
<t-input
v-model="editorForm.standard_question"
:maxlength="200"
class="full-width-input"
/>
</div>
</div>
<!-- 相似问 -->
<div class="setting-row vertical setting-row-optional setting-row-similar">
<div class="setting-info">
<label class="optional-label">{{ $t('knowledgeEditor.faq.similarQuestions') }}</label>
<p class="desc optional-desc">{{ $t('knowledgeEditor.faq.similarQuestionsDesc') }}</p>
</div>
<div class="setting-control">
<div class="full-width-input-wrapper">
<t-input
v-model="similarInput"
:placeholder="$t('knowledgeEditor.faq.similarPlaceholder')"
@keydown.enter.prevent="addSimilar"
class="full-width-input"
/>
<t-button
theme="primary"
variant="outline"
:disabled="!similarInput.trim() || editorForm.similar_questions.length >= 10"
@click="addSimilar"
class="add-item-btn"
size="small"
>
<t-icon name="add" size="16px" />
</t-button>
</div>
<div v-if="editorForm.similar_questions.length > 0" class="item-list">
<div
v-for="(question, index) in editorForm.similar_questions"
:key="index"
class="item-row"
>
<div class="item-content">{{ question }}</div>
<t-button
theme="default"
variant="text"
size="small"
@click="removeSimilar(index)"
class="remove-item-btn"
>
<t-icon name="close" size="16px" />
</t-button>
</div>
</div>
</div>
</div>
<!-- 反例 -->
<div class="setting-row vertical setting-row-optional setting-row-negative">
<div class="setting-info">
<label class="optional-label">{{ $t('knowledgeEditor.faq.negativeQuestions') }}</label>
<p class="desc optional-desc">{{ $t('knowledgeEditor.faq.negativeQuestionsDesc') }}</p>
</div>
<div class="setting-control">
<div class="full-width-input-wrapper">
<t-input
v-model="negativeInput"
:placeholder="$t('knowledgeEditor.faq.negativePlaceholder')"
@keydown.enter.prevent="addNegative"
class="full-width-input"
/>
<t-button
theme="primary"
variant="outline"
:disabled="!negativeInput.trim() || editorForm.negative_questions.length >= 10"
@click="addNegative"
class="add-item-btn"
size="small"
>
<t-icon name="add" size="16px" />
</t-button>
</div>
<div v-if="editorForm.negative_questions.length > 0" class="item-list">
<div
v-for="(question, index) in editorForm.negative_questions"
:key="index"
class="item-row negative"
>
<div class="item-content">{{ question }}</div>
<t-button
theme="default"
variant="text"
size="small"
@click="removeNegative(index)"
class="remove-item-btn"
>
<t-icon name="close" size="16px" />
</t-button>
</div>
</div>
</div>
</div>
<!-- 答案 -->
<div class="setting-row vertical setting-row-primary setting-row-answer">
<div class="setting-info">
<label class="required-label">
{{ $t('knowledgeEditor.faq.answers') }}
<span class="required-mark">*</span>
</label>
<p class="desc">{{ $t('knowledgeEditor.faq.answersDesc') }}</p>
</div>
<div class="setting-control">
<div class="textarea-container">
<div class="full-width-input-wrapper textarea-wrapper">
<t-textarea
v-model="answerInput"
:placeholder="$t('knowledgeEditor.faq.answerPlaceholder')"
:autosize="{ minRows: 3, maxRows: 6 }"
class="full-width-textarea"
@keydown.ctrl.enter="addAnswer"
@keydown.meta.enter="addAnswer"
/>
<t-button
theme="primary"
variant="outline"
:disabled="!answerInput.trim() || editorForm.answers.length >= 5"
@click="addAnswer"
class="add-item-btn"
size="small"
>
<t-icon name="add" size="16px" />
</t-button>
</div>
<div class="item-count">{{ editorForm.answers.length }}/5</div>
</div>
<div v-if="editorForm.answers.length > 0" class="item-list">
<div
v-for="(answer, index) in editorForm.answers"
:key="index"
class="item-row answer-row"
>
<div class="item-content">{{ answer }}</div>
<t-button
theme="default"
variant="text"
size="small"
@click="removeAnswer(index)"
class="remove-item-btn"
>
<t-icon name="close" size="16px" />
</t-button>
</div>
</div>
</div>
</div>
<div class="setting-row vertical">
<div class="setting-info">
<label>{{ $t('knowledgeBase.category') || '分类' }}</label>
<p class="desc">{{ $t('knowledgeEditor.faq.tagDesc') || '为 FAQ 条目选择分类' }}</p>
</div>
<div class="setting-control">
<t-select
v-model="editorForm.tag_id"
class="full-width-input"
:options="tagSelectOptions"
clearable
:placeholder="$t('knowledgeEditor.faq.tagPlaceholder') || '选择分类'"
/>
</div>
</div>
</div>
</t-form>
</div>
<template #footer>
<div class="faq-editor-drawer-footer">
<t-button theme="default" variant="outline" @click="editorVisible = false">
{{ $t('common.cancel') }}
</t-button>
<t-button theme="primary" @click="handleSubmitEntry" :loading="savingEntry">
{{ editorMode === 'create' ? $t('knowledgeEditor.faq.editorCreate') : $t('common.save') }}
</t-button>
</div>
</template>
</t-drawer>
<!-- Import Dialog -->
<Teleport to="body">
<Transition name="modal">
<div v-if="importVisible" class="faq-import-overlay" @click.self="importVisible = false">
<div class="faq-import-modal">
<!-- 关闭按钮 -->
<button class="close-btn" @click="importVisible = false" :aria-label="$t('general.close')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="faq-import-container">
<div class="faq-import-header">
<h2 class="import-title">{{ $t('knowledgeEditor.faqImport.title') }}</h2>
</div>
<div class="faq-import-content">
<!-- 导入模式选择 -->
<div class="import-form-item">
<label class="import-form-label required">{{ $t('knowledgeEditor.faqImport.modeLabel') }}</label>
<t-radio-group v-model="importState.mode" class="import-radio-group">
<t-radio-button value="append">{{ $t('knowledgeEditor.faqImport.appendMode') }}</t-radio-button>
<t-radio-button value="replace">{{ $t('knowledgeEditor.faqImport.replaceMode') }}</t-radio-button>
</t-radio-group>
</div>
<!-- 文件上传区域 -->
<div class="import-form-item">
<div class="file-label-row">
<label class="import-form-label required">{{ $t('knowledgeEditor.faqImport.fileLabel') }}</label>
<t-dropdown
:options="downloadExampleOptions"
placement="bottom-right"
trigger="click"
@click="handleDownloadExample"
class="download-example-dropdown"
>
<t-button theme="default" variant="outline" size="small" class="download-example-btn">
<t-icon name="download" size="16px" />
<span>{{ $t('knowledgeEditor.faqImport.downloadExample') }}</span>
</t-button>
</t-dropdown>
</div>
<div class="file-upload-wrapper">
<input
ref="fileInputRef"
type="file"
accept=".json,.csv,.xlsx,.xls"
@change="handleFileChange"
class="file-input-hidden"
/>
<div
class="file-upload-area"
:class="{ 'has-file': importState.file }"
@click="fileInputRef?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
>
<div class="file-upload-content">
<t-icon name="upload" size="32px" class="upload-icon" />
<div class="upload-text">
<span v-if="!importState.file" class="upload-primary-text">
{{ $t('knowledgeEditor.faqImport.clickToUpload') }}
</span>
<span v-else class="upload-file-name">
{{ importState.file.name }}
</span>
<span v-if="!importState.file" class="upload-secondary-text">
{{ $t('knowledgeEditor.faqImport.dragDropTip') }}
</span>
</div>
</div>
</div>
<p class="import-form-tip">{{ $t('knowledgeEditor.faqImport.fileTip') }}</p>
</div>
</div>
<!-- 预览区域 -->
<div v-if="importState.preview.length" class="import-preview">
<div class="preview-header">
<t-icon name="file-view" size="16px" class="preview-icon" />
<span class="preview-title">
{{ $t('knowledgeEditor.faqImport.previewCount', { count: importState.preview.length }) }}
</span>
</div>
<div class="preview-list">
<div
v-for="(item, index) in importState.preview.slice(0, 5)"
:key="index"
class="preview-item"
>
<span class="preview-index">{{ index + 1 }}</span>
<span class="preview-question">{{ item.standard_question }}</span>
</div>
</div>
<p v-if="importState.preview.length > 5" class="preview-more">
{{ $t('knowledgeEditor.faqImport.previewMore', { count: importState.preview.length - 5 }) }}
</p>
</div>
</div>
<div class="faq-import-footer">
<t-button
theme="default"
variant="outline"
@click="handleCancelImport"
:disabled="importState.importing && importState.taskStatus?.status === 'running'"
>
{{ $t('common.cancel') }}
</t-button>
<t-button
theme="primary"
@click="handleImport"
:loading="importState.importing && !importState.taskId"
:disabled="importState.taskStatus?.status === 'running'"
>
{{ importState.taskStatus?.status === 'success' ? $t('common.close') :
importState.taskStatus?.status === 'failed' ? '重试' :
$t('knowledgeEditor.faqImport.importButton') }}
</t-button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Batch Tag Dialog -->
<Teleport to="body">
<Transition name="modal">
<div v-if="batchTagDialogVisible" class="batch-tag-overlay" @click.self="batchTagDialogVisible = false">
<div class="batch-tag-modal">
<!-- 关闭按钮 -->
<button class="batch-tag-close-btn" @click="batchTagDialogVisible = false" :aria-label="$t('general.close')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="batch-tag-container">
<div class="batch-tag-header">
<h2 class="batch-tag-title">{{ $t('knowledgeEditor.faq.batchUpdateTag') }}</h2>
</div>
<div class="batch-tag-content">
<div class="batch-tag-tip">
<t-icon name="info-circle" size="16px" class="tip-icon" />
<span>{{ $t('knowledgeEditor.faq.batchUpdateTagTip', { count: selectedRowKeys.length }) }}</span>
</div>
<t-form layout="vertical" class="batch-tag-form">
<t-form-item :label="$t('knowledgeBase.tagLabel')">
<t-select
v-model="batchTagValue"
:options="tagSelectOptions"
:placeholder="$t('knowledgeBase.tagPlaceholder')"
clearable
filterable
class="batch-tag-select"
>
<template #empty>
<div class="tag-select-empty">
{{ $t('knowledgeBase.noTags') }}
</div>
</template>
</t-select>
</t-form-item>
</t-form>
</div>
<div class="batch-tag-footer">
<t-button
theme="default"
variant="outline"
@click="batchTagDialogVisible = false"
>
{{ $t('common.cancel') }}
</t-button>
<t-button
theme="primary"
@click="handleBatchTag"
>
{{ $t('common.confirm') }}
</t-button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Search Test Drawer -->
<t-drawer
v-model:visible="searchDrawerVisible"
:header="$t('knowledgeEditor.faq.searchTestTitle')"
:close-btn="true"
size="420px"
placement="right"
class="faq-search-drawer"
>
<div class="search-test-content">
<t-form layout="vertical" class="search-form" :label-width="0">
<div class="settings-group">
<!-- 查询文本 -->
<div class="setting-row vertical search-first-row">
<div class="setting-info">
<label>{{ $t('knowledgeEditor.faq.queryLabel') }}</label>
<p class="desc">{{ $t('knowledgeEditor.faq.queryPlaceholder') }}</p>
</div>
<div class="setting-control">
<t-input
v-model="searchForm.query"
:placeholder="$t('knowledgeEditor.faq.queryPlaceholder')"
@keydown.enter.prevent="handleSearch"
class="full-width-input"
/>
</div>
</div>
<!-- 相似度阈值 -->
<div class="setting-row vertical">
<div class="setting-info">
<label>{{ $t('knowledgeEditor.faq.similarityThresholdLabel') }}</label>
<p class="desc">{{ $t('knowledgeEditor.faq.vectorThresholdDesc') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider
v-model="searchForm.vectorThreshold"
:min="0"
:max="1"
:step="0.1"
:show-tooltip="true"
:format-tooltip="(val: number) => val.toFixed(2)"
/>
<div class="slider-value">{{ searchForm.vectorThreshold.toFixed(2) }}</div>
</div>
</div>
</div>
<!-- 匹配数量 -->
<div class="setting-row vertical">
<div class="setting-info">
<label>{{ $t('knowledgeEditor.faq.matchCountLabel') }}</label>
<p class="desc">{{ $t('knowledgeEditor.faq.matchCountDesc') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider
v-model="searchForm.matchCount"
:min="1"
:max="50"
:step="1"
:show-tooltip="true"
/>
<div class="slider-value">{{ searchForm.matchCount }}</div>
</div>
</div>
</div>
<!-- 搜索按钮 -->
<div class="setting-row vertical">
<div class="setting-control">
<t-button
theme="primary"
block
:loading="searching"
@click="handleSearch"
class="search-button"
>
{{ searching ? $t('knowledgeEditor.faq.searching') : $t('knowledgeEditor.faq.searchButton') }}
</t-button>
</div>
</div>
</div>
</t-form>
<!-- Search Results -->
<div v-if="searchResults.length > 0 || hasSearched" class="search-results">
<div class="results-header">
<span>{{ $t('knowledgeEditor.faq.searchResults') }} ({{ searchResults.length }})</span>
</div>
<div v-if="searchResults.length === 0" class="no-results">
{{ $t('knowledgeEditor.faq.noResults') }}
</div>
<div v-else class="results-list">
<div
v-for="(result, index) in searchResults"
:key="result.id"
class="result-card"
:class="{ 'expanded': result.expanded }"
>
<div class="result-header" @click="toggleResult(result)">
<div class="result-question-wrapper">
<div class="result-question">
<span class="result-index">{{ index + 1 }}.</span>
{{ result.standard_question }}
</div>
<div class="result-meta">
<t-tag size="small" variant="light-outline" class="score-tag">
{{ $t('knowledgeEditor.faq.score') }}: {{ (result.score || 0).toFixed(3) }}
</t-tag>
</div>
<t-icon
:name="result.expanded ? 'chevron-up' : 'chevron-down'"
class="expand-icon"
/>
</div>
</div>
<Transition name="slide-down">
<div v-if="result.expanded" class="result-body">
<div v-if="result.answers?.length" class="result-section">
<div class="section-label">{{ $t('knowledgeEditor.faq.answers') }}</div>
<div class="result-tags">
<t-tooltip
v-for="answer in result.answers"
:key="answer"
:content="answer"
placement="top"
>
<t-tag size="small" theme="success" variant="light" class="answer-tag">
{{ answer }}
</t-tag>
</t-tooltip>
</div>
</div>
<div v-if="result.similar_questions?.length" class="result-section">
<div class="section-label">{{ $t('knowledgeEditor.faq.similarQuestions') }}</div>
<div class="result-tags">
<t-tooltip
v-for="question in result.similar_questions"
:key="question"
:content="question"
placement="top"
>
<t-tag size="small" variant="light-outline" class="question-tag">
{{ question }}
</t-tag>
</t-tooltip>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
</div>
</t-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted, computed, nextTick, onUnmounted, h } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { MessagePlugin, DialogPlugin, Icon as TIcon } from 'tdesign-vue-next'
import type { FormRules, FormInstanceFunctions } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
listFAQEntries,
upsertFAQEntries,
createFAQEntry,
updateFAQEntry,
updateFAQEntryFieldsBatch,
deleteFAQEntries,
searchFAQEntries,
exportFAQEntries,
listKnowledgeTags,
updateFAQEntryTagBatch,
createKnowledgeBaseTag,
updateKnowledgeBaseTag,
deleteKnowledgeBaseTag,
getKnowledgeBaseById,
listKnowledgeBases,
getFAQImportProgress,
} from '@/api/knowledge-base'
import * as XLSX from 'xlsx'
import Papa from 'papaparse'
import FAQTagTooltip from '@/components/FAQTagTooltip.vue'
import { useUIStore } from '@/stores/ui'
interface FAQEntry {
id: string
chunk_id: string
knowledge_id: string
knowledge_base_id: string
tag_id?: string
is_enabled: boolean
is_recommended: boolean
standard_question: string
similar_questions: string[]
negative_questions: string[]
answers: string[]
updated_at: string
showMore?: boolean
score?: number
match_type?: string
expanded?: boolean
similarCollapsed?: boolean
negativeCollapsed?: boolean
answersCollapsed?: boolean
}
interface FAQEntryPayload {
standard_question: string
similar_questions: string[]
negative_questions: string[]
answers: string[]
tag_id?: string
tag_name?: string
is_enabled?: boolean
is_recommended?: boolean
}
const props = defineProps<{
kbId: string
}>()
const { t } = useI18n()
const router = useRouter()
const uiStore = useUIStore()
const loading = ref(false)
const loadingMore = ref(false)
const entries = ref<FAQEntry[]>([])
const entryStatusLoading = reactive<Record<string, boolean>>({})
const entryRecommendedLoading = reactive<Record<string, boolean>>({})
const selectedRowKeys = ref<string[]>([])
const scrollContainer = ref<HTMLElement | null>(null)
const cardListRef = ref<HTMLElement | null>(null)
const hasMore = ref(true)
const pageSize = 20
let currentPage = 1
const entrySearchKeyword = ref('')
let entrySearchDebounce: ReturnType<typeof setTimeout> | null = null
type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>
const tagList = ref<any[]>([])
const tagLoading = ref(false)
const tagListRef = ref<HTMLElement | null>(null)
// Special value to represent "untagged" filter - must match backend constant
const UNTAGGED_FILTER = '__untagged__'
const selectedTagId = ref<string>(UNTAGGED_FILTER)
const overallFAQTotal = ref(0)
const tagSearchQuery = ref('')
const TAG_PAGE_SIZE = 20
const tagPage = ref(1)
const tagHasMore = ref(false)
const tagLoadingMore = ref(false)
const tagTotal = ref(0)
let tagSearchDebounce: ReturnType<typeof setTimeout> | null = null
const editingTagInputRefs = new Map<string, TagInputInstance | null>()
const setEditingTagInputRef = (el: TagInputInstance | null, tagId: string) => {
if (el) {
editingTagInputRefs.set(tagId, el)
} else {
editingTagInputRefs.delete(tagId)
}
}
const setEditingTagInputRefByTag = (tagId: string) => (el: TagInputInstance | null) => {
setEditingTagInputRef(el, tagId)
}
const newTagInputRef = ref<TagInputInstance | null>(null)
const creatingTag = ref(false)
const creatingTagLoading = ref(false)
const newTagName = ref('')
const editingTagId = ref<string | null>(null)
const editingTagName = ref('')
const editingTagSubmitting = ref(false)
const tagDropdownOptions = computed(() => [
{ content: t('knowledgeBase.untagged') || '未分类', value: '' },
...tagList.value.map((tag: any) => ({ content: tag.name, value: tag.id })),
])
const tagMap = computed<Record<string, any>>(() => {
const map: Record<string, any> = {}
tagList.value.forEach((tag) => {
map[tag.id] = tag
})
return map
})
const tagSelectOptions = computed(() => [
{ label: t('knowledgeBase.untagged') || '未分类', value: '' },
...tagList.value.map((tag: any) => ({ label: tag.name, value: tag.id })),
])
const sidebarCategoryCount = computed(() => tagList.value.length + 1)
const assignedFAQCount = computed(() =>
tagList.value.reduce((sum, tag) => sum + (tag.chunk_count || 0), 0),
)
const untaggedFAQCount = computed(() =>
Math.max(overallFAQTotal.value - assignedFAQCount.value, 0),
)
const filteredTags = computed(() => {
const query = tagSearchQuery.value.trim().toLowerCase()
if (!query) {
return tagList.value
}
return tagList.value.filter((tag) => (tag.name || '').toLowerCase().includes(query))
})
const kbInfo = ref<any>(null)
const knowledgeList = ref<Array<{ id: string; name: string; type?: string }>>([])
const knowledgeDropdownOptions = computed(() =>
knowledgeList.value
.map((item) => ({
content: item.name,
value: item.id,
prefixIcon: () => h(TIcon, { name: item.type === 'document' ? 'folder' : 'chat-bubble-help', size: '16px' }),
})),
)
const loadKnowledgeInfo = async (kbId: string) => {
if (!kbId) {
kbInfo.value = null
return
}
try {
const res: any = await getKnowledgeBaseById(kbId)
kbInfo.value = res?.data || null
return kbInfo.value
} catch (error) {
console.error('Failed to load knowledge base info:', error)
kbInfo.value = null
return null
}
}
const loadKnowledgeList = async () => {
try {
const res: any = await listKnowledgeBases()
knowledgeList.value = (res?.data || []).map((item: any) => ({
id: String(item.id),
name: item.name,
type: item.type,
}))
} catch (error) {
console.error('Failed to load knowledge bases:', error)
}
}
const editorVisible = ref(false)
const editorMode = ref<'create' | 'edit'>('create')
const currentEntryId = ref<string | null>(null)
const editorForm = reactive<FAQEntryPayload>({
standard_question: '',
similar_questions: [],
negative_questions: [],
answers: [],
tag_id: '',
})
const editorFormRef = ref<FormInstanceFunctions>()
const savingEntry = ref(false)
// 输入框状态
const answerInput = ref('')
const similarInput = ref('')
const negativeInput = ref('')
const importVisible = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const importState = reactive({
mode: 'append' as 'append' | 'replace',
file: null as File | null,
preview: [] as FAQEntryPayload[],
importing: false,
taskId: null as string | null,
taskStatus: null as {
status: string
progress: number
total: number
processed: number
error?: string
} | null,
pollingInterval: null as ReturnType<typeof setInterval> | null,
})
// Search test state
const searchDrawerVisible = ref(false)
const searching = ref(false)
const hasSearched = ref(false)
const searchResults = ref<FAQEntry[]>([])
const searchForm = reactive({
query: '',
vectorThreshold: 0.7,
matchCount: 10,
})
// 标签列表滚动加载更多
const handleTagListScroll = () => {
const container = tagListRef.value
if (!container) return
if (tagLoadingMore.value || !tagHasMore.value) return
const { scrollTop, scrollHeight, clientHeight } = container
// 距离底部 50px 时触发加载
if (scrollTop + clientHeight >= scrollHeight - 50) {
loadTags()
}
}
const loadTags = async (reset = false) => {
if (!props.kbId) {
tagList.value = []
tagTotal.value = 0
tagHasMore.value = false
tagPage.value = 1
return
}
if (reset) {
tagPage.value = 1
tagList.value = []
tagTotal.value = 0
tagHasMore.value = false
}
const currentPage = tagPage.value || 1
tagLoading.value = currentPage === 1
tagLoadingMore.value = currentPage > 1
try {
const res: any = await listKnowledgeTags(props.kbId, {
page: currentPage,
page_size: TAG_PAGE_SIZE,
keyword: tagSearchQuery.value || undefined,
})
const pageData = (res?.data || {}) as {
data?: any[]
total?: number
}
const pageTags = (pageData.data || []).map((tag: any) => ({
...tag,
id: String(tag.id),
}))
if (currentPage === 1) {
tagList.value = pageTags
} else {
tagList.value = [...tagList.value, ...pageTags]
}
tagTotal.value = pageData.total || tagList.value.length
tagHasMore.value = tagList.value.length < tagTotal.value
if (tagHasMore.value) {
tagPage.value = currentPage + 1
}
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
} finally {
tagLoading.value = false
tagLoadingMore.value = false
}
}
const getTagName = (tagId?: string) => {
if (!tagId) return t('knowledgeBase.untagged') || '未分类'
return tagMap.value[tagId]?.name || (t('knowledgeBase.untagged') || '未分类')
}
const handleTagFilterChange = (value: string) => {
selectedTagId.value = value
}
const handleTagRowClick = (tagId: string) => {
const normalizedId = String(tagId)
if (editingTagId.value && editingTagId.value !== normalizedId) {
cancelEditTag()
}
if (creatingTag.value) {
cancelCreateTag()
}
if (selectedTagId.value === normalizedId) {
return
}
handleTagFilterChange(normalizedId)
}
const handleUntaggedClick = () => {
if (creatingTag.value) {
cancelCreateTag()
}
if (editingTagId.value) {
cancelEditTag()
}
if (selectedTagId.value === UNTAGGED_FILTER) return
handleTagFilterChange(UNTAGGED_FILTER)
}
const startCreateTag = () => {
if (!props.kbId) {
MessagePlugin.warning(t('knowledgeEditor.messages.missingId'))
return
}
if (creatingTag.value) {
return
}
cancelEditTag()
creatingTag.value = true
nextTick(() => {
newTagInputRef.value?.focus?.()
newTagInputRef.value?.select?.()
})
}
const cancelCreateTag = () => {
creatingTag.value = false
newTagName.value = ''
}
const submitCreateTag = async () => {
if (!props.kbId) {
MessagePlugin.warning(t('knowledgeEditor.messages.missingId'))
return
}
const name = newTagName.value.trim()
if (!name) {
MessagePlugin.warning(t('knowledgeBase.tagNameRequired'))
return
}
creatingTagLoading.value = true
try {
await createKnowledgeBaseTag(props.kbId, { name })
MessagePlugin.success(t('knowledgeBase.tagCreateSuccess'))
cancelCreateTag()
await loadTags()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
} finally {
creatingTagLoading.value = false
}
}
const startEditTag = (tag: any) => {
cancelCreateTag()
editingTagId.value = tag.id
editingTagName.value = tag.name
nextTick(() => {
const inputRef = editingTagInputRefs.get(tag.id)
inputRef?.focus?.()
inputRef?.select?.()
})
}
const cancelEditTag = () => {
editingTagId.value = null
editingTagName.value = ''
}
const submitEditTag = async () => {
if (!props.kbId || !editingTagId.value) {
return
}
const name = editingTagName.value.trim()
if (!name) {
MessagePlugin.warning(t('knowledgeBase.tagNameRequired'))
return
}
if (name === tagMap.value[editingTagId.value]?.name) {
cancelEditTag()
return
}
editingTagSubmitting.value = true
try {
await updateKnowledgeBaseTag(props.kbId, editingTagId.value, { name })
MessagePlugin.success(t('knowledgeBase.tagEditSuccess'))
cancelEditTag()
await loadTags()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
} finally {
editingTagSubmitting.value = false
}
}
const confirmDeleteTag = (tag: any) => {
if (!props.kbId) {
MessagePlugin.warning(t('knowledgeEditor.messages.missingId'))
return
}
if (creatingTag.value) {
cancelCreateTag()
}
if (editingTagId.value) {
cancelEditTag()
}
const confirmDialog = DialogPlugin.confirm({
header: t('knowledgeBase.tagDeleteTitle'),
body: t('knowledgeBase.tagDeleteDesc', { name: tag.name }),
confirmBtn: { content: t('common.delete'), theme: 'danger' },
cancelBtn: t('common.cancel'),
onConfirm: async () => {
try {
await deleteKnowledgeBaseTag(props.kbId, tag.id, { force: true })
MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess'))
if (selectedTagId.value === tag.id) {
handleTagFilterChange(UNTAGGED_FILTER)
}
await loadTags()
await loadEntries()
confirmDialog.hide()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
}
},
})
}
const handleEntryTagChange = async (entryId: string, value?: string) => {
if (!props.kbId) return
const targetEntry = entries.value.find((item) => item.id === entryId)
const previousTagId = targetEntry ? targetEntry.tag_id : ''
const normalizedValue = value ?? ''
if (normalizedValue === previousTagId) {
return
}
try {
await updateFAQEntryTagBatch(props.kbId, { updates: { [entryId]: normalizedValue || null } })
MessagePlugin.success(t('knowledgeEditor.messages.updateSuccess'))
await loadEntries()
await loadTags()
} catch (error: any) {
if (targetEntry) {
targetEntry.tag_id = previousTagId
}
MessagePlugin.error(error?.message || t('common.operationFailed'))
}
}
const handleNavigateToKbList = () => {
router.push('/platform/knowledge-bases')
}
const handleNavigateToCurrentKB = () => {
if (!props.kbId) return
router.push(`/platform/knowledge-bases/${props.kbId}`)
}
const handleOpenKBSettings = () => {
if (!props.kbId) {
MessagePlugin.warning(t('knowledgeEditor.messages.missingId'))
return
}
uiStore.openKBSettings(props.kbId)
}
const handleKnowledgeDropdownSelect = (data: { value: string }) => {
if (!data?.value || data.value === props.kbId) return
router.push(`/platform/knowledge-bases/${data.value}`)
}
const handleFaqMenuAction = (event: Event) => {
const detail = (event as CustomEvent<{ action: string; kbId: string }>).detail
if (!detail || detail.kbId !== props.kbId) return
if (detail.action === 'create') {
openEditor()
} else if (detail.action === 'import') {
openImportDialog()
} else if (detail.action === 'search') {
searchDrawerVisible.value = true
} else if (detail.action === 'export') {
handleExportCSV()
} else if (detail.action === 'batch') {
// 批量操作通过左侧菜单的下拉菜单处理
if (selectedRowKeys.value.length === 0) {
MessagePlugin.warning(t('knowledgeEditor.faq.selectEntriesFirst') || '请先选中要操作的FAQ条目')
}
} else if (detail.action === 'batchTag') {
if (selectedRowKeys.value.length > 0) {
openBatchTagDialog()
}
} else if (detail.action === 'batchEnable') {
if (selectedRowKeys.value.length > 0) {
handleBatchStatusChange(true)
}
} else if (detail.action === 'batchDisable') {
if (selectedRowKeys.value.length > 0) {
handleBatchStatusChange(false)
}
} else if (detail.action === 'batchDelete') {
if (selectedRowKeys.value.length > 0) {
handleBatchDelete()
}
}
}
const handleEntryStatusChange = async (entry: FAQEntry, value: boolean) => {
if (!props.kbId) {
return
}
const entryIndex = entries.value.findIndex(e => e.id === entry.id)
if (entryIndex === -1) {
return
}
// 从数组中获取实际的对象引用,确保使用最新的数据
const actualEntry = entries.value[entryIndex]
const previous = actualEntry.is_enabled
if (previous === value) {
return
}
// 直接更新属性,Vue 3 的响应式系统应该能够检测到
actualEntry.is_enabled = value
entryStatusLoading[entry.id] = true
try {
await updateFAQEntryFieldsBatch(props.kbId, { by_id: { [entry.id]: { is_enabled: value } } })
MessagePlugin.success(t(value ? 'knowledgeEditor.faq.statusEnableSuccess' : 'knowledgeEditor.faq.statusDisableSuccess'))
} catch (error: any) {
// 失败时回滚
actualEntry.is_enabled = previous
MessagePlugin.error(error?.message || t('knowledgeEditor.faq.statusUpdateFailed'))
} finally {
entryStatusLoading[entry.id] = false
}
}
const handleEntryRecommendedChange = async (entry: FAQEntry, value: boolean) => {
if (entryRecommendedLoading[entry.id]) {
return
}
const entryIndex = entries.value.findIndex(e => e.id === entry.id)
if (entryIndex === -1) {
return
}
const actualEntry = entries.value[entryIndex]
const previous = actualEntry.is_recommended
if (previous === value) {
return
}
actualEntry.is_recommended = value
entryRecommendedLoading[entry.id] = true
try {
await updateFAQEntryFieldsBatch(props.kbId, { by_id: { [entry.id]: { is_recommended: value } } })
MessagePlugin.success(t(value ? 'knowledgeEditor.faq.recommendedEnableSuccess' : 'knowledgeEditor.faq.recommendedDisableSuccess'))
} catch (error: any) {
actualEntry.is_recommended = previous
MessagePlugin.error(error?.message || t('knowledgeEditor.faq.recommendedUpdateFailed'))
} finally {
entryRecommendedLoading[entry.id] = false
}
}
const editorRules: FormRules<FAQEntryPayload> = {
standard_question: [
{ required: true, message: t('knowledgeEditor.messages.nameRequired') },
],
answers: [
{
validator: (val: string[]) => Array.isArray(val) && val.length > 0,
message: t('knowledgeEditor.faq.answerRequired'),
},
],
}
const loadEntries = async (append = false) => {
if (!props.kbId) return
if (append) {
loadingMore.value = true
} else {
loading.value = true
currentPage = 1
entries.value = []
selectedRowKeys.value = []
Object.keys(entryStatusLoading).forEach((key) => {
delete entryStatusLoading[key]
})
}
try {
// If overallFAQTotal is not initialized, fetch it first (without tag_id filter)
if (overallFAQTotal.value === 0 && !append) {
const totalRes = await listFAQEntries(props.kbId, {
page: 1,
page_size: 1,
})
const totalData = (totalRes.data || {}) as { total: number }
overallFAQTotal.value = totalData.total || 0
}
const res = await listFAQEntries(props.kbId, {
page: currentPage,
page_size: pageSize,
tag_id: selectedTagId.value || undefined,
keyword: entrySearchKeyword.value ? entrySearchKeyword.value.trim() : undefined,
})
const pageData = (res.data || {}) as {
data: FAQEntry[]
total: number
}
const newEntries = (pageData.data || []).map(entry => ({
...entry,
showMore: false,
similarCollapsed: true, // 相似问默认折叠
negativeCollapsed: true, // 反例默认折叠
answersCollapsed: true, // 答案默认折叠
tag_id: entry.tag_id ? String(entry.tag_id) : '',
is_enabled: entry.is_enabled !== false,
}))
if (append) {
entries.value = [...entries.value, ...newEntries]
} else {
entries.value = newEntries
}
// 判断是否还有更多数据
hasMore.value = entries.value.length < (pageData.total || 0)
currentPage++
// 等待 DOM 更新后重新布局
await nextTick()
arrangeCards()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
} finally {
loading.value = false
loadingMore.value = false
// 检查是否需要继续加载以填满可视区域
// 延迟执行以确保 arrangeCards 的 requestAnimationFrame 完成
setTimeout(() => {
checkAndLoadMore()
}, 350)
}
}
const handleScroll = () => {
if (!scrollContainer.value || loadingMore.value || !hasMore.value) return
const container = scrollContainer.value
const scrollTop = container.scrollTop
const scrollHeight = container.scrollHeight
const clientHeight = container.clientHeight
// 当滚动到距离底部 200px 时加载更多
if (scrollTop + clientHeight >= scrollHeight - 200) {
loadEntries(true)
}
}
// 检查内容是否填满可视区域,如果没有且还有更多数据,继续加载
const checkAndLoadMore = () => {
if (!scrollContainer.value) return
if (loadingMore.value || loading.value) return
if (!hasMore.value) return
const container = scrollContainer.value
const scrollHeight = container.scrollHeight
const clientHeight = container.clientHeight
// 如果内容高度小于容器高度 + 50px 的缓冲,说明可能没有滚动条或接近底部,需要继续加载
if (scrollHeight <= clientHeight + 50) {
loadEntries(true)
}
}
const handleCardSelect = (entryId: string, checked: boolean) => {
if (checked) {
if (!selectedRowKeys.value.includes(entryId)) {
selectedRowKeys.value.push(entryId)
}
} else {
const index = selectedRowKeys.value.indexOf(entryId)
if (index > -1) {
selectedRowKeys.value.splice(index, 1)
}
}
}
const resetEditorForm = () => {
editorForm.standard_question = ''
editorForm.similar_questions = []
editorForm.negative_questions = []
editorForm.answers = []
editorForm.tag_id = ''
answerInput.value = ''
similarInput.value = ''
negativeInput.value = ''
}
const openEditor = (entry?: FAQEntry) => {
if (entry) {
editorMode.value = 'edit'
currentEntryId.value = entry.id
editorForm.standard_question = entry.standard_question
editorForm.similar_questions = [...(entry.similar_questions || [])]
editorForm.negative_questions = [...(entry.negative_questions || [])]
editorForm.answers = [...(entry.answers || [])]
editorForm.tag_id = entry.tag_id || ''
} else {
editorMode.value = 'create'
currentEntryId.value = null
resetEditorForm()
}
answerInput.value = ''
similarInput.value = ''
negativeInput.value = ''
editorVisible.value = true
}
const handleEditorClose = () => {
// 关闭时重置表单
resetEditorForm()
answerInput.value = ''
similarInput.value = ''
negativeInput.value = ''
editorFormRef.value?.clearValidate?.()
}
// 添加答案
const addAnswer = () => {
const trimmed = answerInput.value.trim()
if (trimmed && editorForm.answers.length < 5 && !editorForm.answers.includes(trimmed)) {
editorForm.answers.push(trimmed)
answerInput.value = ''
}
}
// 删除答案
const removeAnswer = (index: number) => {
editorForm.answers.splice(index, 1)
}
// 添加相似问
const addSimilar = () => {
const trimmed = similarInput.value.trim()
if (trimmed && editorForm.similar_questions.length < 10 && !editorForm.similar_questions.includes(trimmed)) {
editorForm.similar_questions.push(trimmed)
similarInput.value = ''
}
}
// 删除相似问
const removeSimilar = (index: number) => {
editorForm.similar_questions.splice(index, 1)
}
// 添加反例
const addNegative = () => {
const trimmed = negativeInput.value.trim()
if (trimmed && editorForm.negative_questions.length < 10 && !editorForm.negative_questions.includes(trimmed)) {
editorForm.negative_questions.push(trimmed)
negativeInput.value = ''
}
}
// 删除反例
const removeNegative = (index: number) => {
editorForm.negative_questions.splice(index, 1)
}
const handleSubmitEntry = async () => {
if (!editorFormRef.value) return
const result = await editorFormRef.value.validate?.()
if (result !== true) return
savingEntry.value = true
try {
const payload: FAQEntryPayload = {
standard_question: editorForm.standard_question,
similar_questions: [...editorForm.similar_questions],
negative_questions: [...editorForm.negative_questions],
answers: [...editorForm.answers],
tag_id: editorForm.tag_id || '',
}
if (editorMode.value === 'create') {
await createFAQEntry(props.kbId, payload)
MessagePlugin.success(t('knowledgeEditor.messages.createSuccess'))
} else if (currentEntryId.value) {
await updateFAQEntry(props.kbId, currentEntryId.value, payload)
MessagePlugin.success(t('knowledgeEditor.messages.updateSuccess'))
}
editorVisible.value = false
await loadEntries()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
} finally {
savingEntry.value = false
}
}
const handleBatchDelete = async () => {
if (!selectedRowKeys.value.length) return
try {
await deleteFAQEntries(props.kbId, selectedRowKeys.value)
MessagePlugin.success(t('knowledgeEditor.faqImport.deleteSuccess'))
selectedRowKeys.value = []
await loadEntries()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
}
}
// 批量状态更新对话框
const batchTagDialogVisible = ref(false)
const batchTagValue = ref<string>('')
const openBatchTagDialog = () => {
if (!selectedRowKeys.value.length) return
batchTagValue.value = ''
batchTagDialogVisible.value = true
}
const handleBatchTag = async () => {
if (!selectedRowKeys.value.length || !props.kbId) return
try {
const updates: Record<string, string | null> = {}
selectedRowKeys.value.forEach(id => {
updates[id] = batchTagValue.value || null
})
await updateFAQEntryTagBatch(props.kbId, { updates })
MessagePlugin.success(t('knowledgeEditor.messages.updateSuccess'))
batchTagDialogVisible.value = false
selectedRowKeys.value = []
await loadEntries()
await loadTags()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
}
}
const handleBatchStatusChange = async (isEnabled: boolean) => {
if (!selectedRowKeys.value.length || !props.kbId) return
try {
const by_id: Record<string, { is_enabled: boolean }> = {}
selectedRowKeys.value.forEach(id => {
by_id[id] = { is_enabled: isEnabled }
})
await updateFAQEntryFieldsBatch(props.kbId, { by_id })
MessagePlugin.success(t(isEnabled ? 'knowledgeEditor.faq.statusEnableSuccess' : 'knowledgeEditor.faq.statusDisableSuccess'))
selectedRowKeys.value = []
await loadEntries()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
}
}
const handleBatchRecommendedChange = async (isRecommended: boolean) => {
if (!selectedRowKeys.value.length || !props.kbId) return
try {
const by_id: Record<string, { is_recommended: boolean }> = {}
selectedRowKeys.value.forEach(id => {
by_id[id] = { is_recommended: isRecommended }
})
await updateFAQEntryFieldsBatch(props.kbId, { by_id })
MessagePlugin.success(t(isRecommended ? 'knowledgeEditor.faq.recommendedEnableSuccess' : 'knowledgeEditor.faq.recommendedDisableSuccess'))
selectedRowKeys.value = []
await loadEntries()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
}
}
const handleMenuEdit = (entry: FAQEntry) => {
entry.showMore = false
openEditor(entry)
}
const handleMenuDelete = async (entry: FAQEntry) => {
entry.showMore = false
try {
await deleteFAQEntries(props.kbId, [entry.id])
MessagePlugin.success(t('knowledgeEditor.faqImport.deleteSuccess'))
await loadEntries()
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
}
}
const openImportDialog = () => {
// 如果正在导入,不允许打开导入对话框
if (importState.taskStatus?.status === 'running') {
MessagePlugin.warning('导入正在进行中,请等待完成后再试')
return
}
stopPolling()
importVisible.value = true
importState.file = null
importState.preview = []
importState.mode = 'append'
// 注意:不清除taskId和taskStatus,以便在关闭对话框后仍能看到进度
importState.importing = false
}
const processFile = async (file: File) => {
importState.file = file
try {
let parsed: FAQEntryPayload[] = []
if (file.name.endsWith('.json')) {
parsed = await parseJSONFile(file)
} else if (file.name.endsWith('.csv')) {
parsed = await parseCSVFile(file)
} else if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
parsed = await parseExcelFile(file)
} else {
MessagePlugin.warning(t('knowledgeEditor.faqImport.unsupportedFormat'))
importState.preview = []
return
}
importState.preview = parsed
} catch (error: any) {
MessagePlugin.error(error?.message || t('knowledgeEditor.faqImport.parseFailed'))
importState.preview = []
}
}
const handleFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
await processFile(file)
}
const handleFileDrop = async (event: DragEvent) => {
const file = event.dataTransfer?.files[0]
if (!file) return
await processFile(file)
}
const parseJSONFile = async (file: File): Promise<FAQEntryPayload[]> => {
const text = await file.text()
const data = JSON.parse(text)
if (!Array.isArray(data)) {
throw new Error(t('knowledgeEditor.faqImport.invalidJSON'))
}
return data.map(normalizePayload)
}
const parseCSVFile = async (file: File): Promise<FAQEntryPayload[]> => {
const text = await file.text()
// 使用 papaparse 解析 CSV,自动处理引号、转义、分隔符等
return new Promise((resolve, reject) => {
Papa.parse(text, {
header: true,
skipEmptyLines: true,
delimiter: '', // 自动检测分隔符(逗号或制表符)
quoteChar: '"',
escapeChar: '"',
transformHeader: (header: string) => {
// 移除字段名中的括号和说明,只保留核心字段名
const cleaned = header.trim()
.replace(/\([^)]*\)/g, '') // 移除括号及内容
.trim()
// 对于中文字段名,不转换为小写;对于英文字段名,转换为小写
return /[\u4e00-\u9fa5]/.test(cleaned) ? cleaned : cleaned.toLowerCase()
},
complete: (results) => {
try {
const payloads: FAQEntryPayload[] = []
results.data.forEach((row: any) => {
const record: Record<string, string> = {}
// 将行数据转换为记录对象
Object.keys(row).forEach((key) => {
record[key] = String(row[key] || '').trim()
})
const isDisabled = parseBooleanField(record['是否停用'], false)
payloads.push(
normalizePayload({
standard_question: record['问题'] || record['standard_question'] || record['question'] || '',
answers: splitByDelimiter(record['机器人回答'] || record['answers']),
similar_questions: splitByDelimiter(record['相似问题'] || record['similar_questions']),
negative_questions: splitByDelimiter(record['反例问题'] || record['negative_questions']),
tag_id: record['tag_id'] || '',
tag_name: record['分类'] || record['tag_name'] || '',
is_enabled: isDisabled !== undefined ? !isDisabled : undefined, // 是否停用:FALSE表示启用,TRUE表示停用,所以取反
}),
)
})
resolve(payloads)
} catch (error) {
reject(error)
}
},
error: (error: Error) => {
reject(new Error(`CSV解析失败: ${error.message}`))
},
})
})
}
const parseExcelFile = async (file: File): Promise<FAQEntryPayload[]> => {
const data = await file.arrayBuffer()
const workbook = XLSX.read(data, { type: 'array' })
const sheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetName]
// 使用 raw: false 确保正确处理引号和转义
const json = XLSX.utils.sheet_to_json<Record<string, string>>(worksheet, {
defval: '',
raw: false // 确保字符串值被正确解析
})
return json.map((row) => {
// 获取原始表头(去除括号说明)
const normalizedRow: Record<string, string> = {}
Object.keys(row).forEach((key) => {
const normalizedKey = key.trim()
.replace(/\([^)]*\)/g, '') // 移除括号及内容
.trim()
// 对于中文字段名,不转换为小写;对于英文字段名,转换为小写
const finalKey = /[\u4e00-\u9fa5]/.test(normalizedKey) ? normalizedKey : normalizedKey.toLowerCase()
// 确保值是字符串类型
normalizedRow[finalKey] = String(row[key] || '').trim()
})
const isDisabled = parseBooleanField(normalizedRow['是否停用'], false)
return normalizePayload({
standard_question: normalizedRow['问题'] || normalizedRow['standard_question'] || normalizedRow['question'] || '',
answers: splitByDelimiter(normalizedRow['机器人回答'] || normalizedRow['answers']),
similar_questions: splitByDelimiter(normalizedRow['相似问题'] || normalizedRow['similar_questions']),
negative_questions: splitByDelimiter(normalizedRow['反例问题'] || normalizedRow['negative_questions']),
tag_id: normalizedRow['tag_id'] || '',
tag_name: normalizedRow['分类'] || normalizedRow['tag_name'] || '',
is_enabled: isDisabled !== undefined ? !isDisabled : undefined, // 是否停用:FALSE表示启用,TRUE表示停用,所以取反
})
})
}
const splitByDelimiter = (value?: string) => {
if (!value) return []
// 只使用 ## 作为分隔符,避免错误分割包含逗号、分号等内容
const trimmedValue = value.trim()
if (!trimmedValue) return []
// 如果包含 ## 分隔符,按 ## 分割
if (trimmedValue.includes('##')) {
return trimmedValue
.split('##')
.map(item => item.trim())
.filter(Boolean)
}
// 如果没有 ## 分隔符,整个值作为一个答案
return [trimmedValue]
}
// 解析布尔字段(支持多种格式:TRUE/FALSE, true/false, 是/否, 1/0等)
const parseBooleanField = (value?: string, defaultValue: boolean = true): boolean | undefined => {
if (!value) return undefined
const normalized = value.trim().toUpperCase()
if (normalized === 'TRUE' || normalized === '1' || normalized === '是' || normalized === 'YES') {
return true
}
if (normalized === 'FALSE' || normalized === '0' || normalized === '否' || normalized === 'NO') {
return false
}
return defaultValue
}
const normalizePayload = (payload: Partial<FAQEntryPayload>): FAQEntryPayload => ({
standard_question: payload.standard_question || '',
answers: payload.answers?.filter(Boolean) || [],
similar_questions: payload.similar_questions?.filter(Boolean) || [],
negative_questions: payload.negative_questions?.filter(Boolean) || [],
tag_id: payload.tag_id || '',
tag_name: payload.tag_name || '',
is_enabled: payload.is_enabled !== undefined ? payload.is_enabled : undefined,
})
const stopPolling = () => {
if (importState.pollingInterval) {
clearInterval(importState.pollingInterval)
importState.pollingInterval = null
}
}
const startPolling = (taskId: string) => {
stopPolling()
// 保存taskId到localStorage,以便刷新后恢复
saveTaskIdToStorage(taskId)
// 记录上次已处理数量,用于判断是否需要刷新列表
let lastProcessed = 0
importState.pollingInterval = setInterval(async () => {
try {
const res: any = await getFAQImportProgress(taskId)
const progressData = res?.data
if (progressData) {
// 从Redis进度数据中提取状态
// status: "pending" -> "pending", "processing" -> "running", "completed" -> "success", "failed" -> "failed"
let status = progressData.status
if (status === 'processing') {
status = 'running'
} else if (status === 'completed') {
status = 'success'
}
const progress = progressData.progress || 0
const total = progressData.total || 0
const processed = progressData.processed || 0
const error = progressData.error || ''
importState.taskStatus = {
status: status,
progress: progress,
total: total,
processed: processed,
error: error,
}
// 进度更新时刷新FAQ列表(每增加一些条目就刷新一次)
if (processed > lastProcessed) {
lastProcessed = processed
await loadEntries()
await loadTags()
}
// 任务完成或失败,停止轮询(但不自动关闭进度条,让用户手动关闭)
if (status === 'success' || status === 'failed') {
stopPolling()
if (status === 'success') {
MessagePlugin.success(t('knowledgeEditor.faqImport.importSuccess'))
// 清除筛选条件,确保用户能看到所有新导入的数据
selectedTagId.value = UNTAGGED_FILTER
entrySearchKeyword.value = ''
overallFAQTotal.value = 0 // Reset to trigger re-fetch
await loadEntries()
await loadTags()
// 任务完成后,3秒后自动关闭进度条
setTimeout(() => {
if (importState.taskStatus?.status === 'success') {
handleCloseProgress()
}
}, 3000)
} else {
MessagePlugin.error(error || t('common.operationFailed'))
// 失败时不自动关闭,让用户看到错误信息
}
}
}
} catch (error: any) {
console.error('Failed to poll task status:', error)
// 如果任务不存在或已过期,清除存储
if (error?.response?.status === 404 || error?.message?.includes('not found')) {
clearTaskIdFromStorage()
stopPolling()
importState.taskId = null
importState.taskStatus = null
}
}
}, 3000) // 每3秒轮询一次
}
const handleCancelImport = () => {
stopPolling()
importState.importing = false
importState.taskId = null
importState.taskStatus = null
importVisible.value = false
// 注意:不清除localStorage,因为任务可能还在进行中
}
const handleCloseProgress = () => {
stopPolling()
importState.taskId = null
importState.taskStatus = null
clearTaskIdFromStorage()
}
// localStorage相关函数
const getStorageKey = () => {
return `faq_import_task_${props.kbId}`
}
const saveTaskIdToStorage = (taskId: string) => {
if (!props.kbId) return
try {
localStorage.setItem(getStorageKey(), taskId)
} catch (error) {
console.error('Failed to save taskId to localStorage:', error)
}
}
const getTaskIdFromStorage = (): string | null => {
if (!props.kbId) return null
try {
return localStorage.getItem(getStorageKey())
} catch (error) {
console.error('Failed to get taskId from localStorage:', error)
return null
}
}
const clearTaskIdFromStorage = () => {
if (!props.kbId) return
try {
localStorage.removeItem(getStorageKey())
} catch (error) {
console.error('Failed to clear taskId from localStorage:', error)
}
}
// 恢复导入任务状态(用于刷新后恢复)
const restoreImportTask = async () => {
if (!props.kbId) return
const savedTaskId = getTaskIdFromStorage()
if (!savedTaskId) return
try {
// 查询Redis中的进度状态
const res: any = await getFAQImportProgress(savedTaskId)
const progressData = res?.data
if (progressData) {
// 从Redis进度数据中提取状态
let status = progressData.status
if (status === 'processing') {
status = 'running'
} else if (status === 'completed') {
status = 'success'
}
const progress = progressData.progress || 0
const total = progressData.total || 0
const processed = progressData.processed || 0
const error = progressData.error || ''
importState.taskId = savedTaskId
importState.taskStatus = {
status: status,
progress: progress,
total: total,
processed: processed,
error: error,
}
// 如果任务还在进行中,恢复轮询
if (status === 'pending' || status === 'running') {
startPolling(savedTaskId)
} else {
// 任务已完成或失败,清除存储
clearTaskIdFromStorage()
}
} else {
// 任务不存在,清除存储
clearTaskIdFromStorage()
}
} catch (error: any) {
console.error('Failed to restore import task:', error)
// 如果任务不存在或已过期,清除存储
if (error?.response?.status === 404 || error?.message?.includes('not found')) {
clearTaskIdFromStorage()
}
}
}
const handleImport = async () => {
if (!importState.file || !importState.preview.length) {
MessagePlugin.warning(t('knowledgeEditor.faqImport.selectFile'))
return
}
// 如果任务已完成或失败,关闭对话框
if (importState.taskStatus?.status === 'success' || importState.taskStatus?.status === 'failed') {
if (importState.taskStatus.status === 'success') {
handleCancelImport()
} else {
// 失败时重试
importState.taskId = null
importState.taskStatus = null
importState.importing = false
}
return
}
importState.importing = true
try {
const res: any = await upsertFAQEntries(props.kbId, {
entries: importState.preview,
mode: importState.mode,
})
const taskId = res?.data?.task_id
if (taskId) {
importState.taskId = taskId
importState.taskStatus = {
status: 'pending',
progress: 0,
total: importState.preview.length,
processed: 0,
}
// 开始轮询任务状态
startPolling(taskId)
// 立即关闭导入对话框,进度将在列表页面顶部显示
importVisible.value = false
// 重置导入对话框状态(但保留taskId和taskStatus用于进度显示)
importState.file = null
importState.preview = []
importState.importing = false
} else {
// 如果没有返回任务ID,可能是旧版本API,使用同步方式
MessagePlugin.success(t('knowledgeEditor.faqImport.importSuccess'))
importVisible.value = false
await loadEntries()
importState.importing = false
}
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
importState.importing = false
stopPolling()
}
}
// 监听选中数量变化,通知左侧菜单
watch(selectedRowKeys, (newKeys, oldKeys) => {
const count = newKeys.length
// 获取选中条目的状态信息
const selectedEntries = entries.value.filter(entry => newKeys.includes(entry.id))
const enabledCount = selectedEntries.filter(entry => entry.is_enabled !== false).length
const disabledCount = count - enabledCount
const event = new CustomEvent('faqSelectionChanged', {
detail: {
count,
enabledCount,
disabledCount
}
})
window.dispatchEvent(event)
}, { immediate: true, deep: true })
// 组件卸载时清理轮询
onUnmounted(() => {
stopPolling()
})
// 下载示例文件选项
const downloadExampleOptions = computed(() => [
{ content: t('knowledgeEditor.faqImport.downloadExampleJSON'), value: 'json' },
{ content: t('knowledgeEditor.faqImport.downloadExampleCSV'), value: 'csv' },
{ content: t('knowledgeEditor.faqImport.downloadExampleExcel'), value: 'excel' },
])
// 示例数据
const exampleData: FAQEntryPayload[] = [
{
standard_question: '什么是 WeKnora?',
answers: ['WeKnora 是一个智能知识库管理系统', '它支持多种知识库类型和导入方式'],
similar_questions: ['WeKnora 是什么?', '介绍一下 WeKnora'],
negative_questions: ['这不是 WeKnora', '与 WeKnora 无关'],
tag_name: '产品介绍',
},
{
standard_question: '如何创建知识库?',
answers: ['点击"新建知识库"按钮', '选择知识库类型并填写相关信息', '完成创建后即可开始使用'],
similar_questions: ['怎么创建知识库?', '如何新建知识库?'],
negative_questions: [],
tag_name: '使用指南',
},
]
// 下载示例文件
const handleDownloadExample = (data: { value: string }) => {
const { value } = data
switch (value) {
case 'json':
downloadJSONExample()
break
case 'csv':
downloadCSVExample()
break
case 'excel':
downloadExcelExample()
break
}
}
// 下载 JSON 示例
const downloadJSONExample = () => {
const jsonStr = JSON.stringify(exampleData, null, 2)
const blob = new Blob([jsonStr], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'faq_example.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// 下载 CSV 示例
const downloadCSVExample = () => {
const headers = ['分类(必填)', '问题(必填)', '相似问题(选填-多个用##分隔)', '反例问题(选填-多个用##分隔)', '机器人回答(必填-多个用##分隔)', '是否全部回复(选填-默认FALSE)', '是否停用(选填-默认FALSE)', '是否禁止被推荐(选填-默认False 可被推荐)']
const rows = exampleData.map((item) => {
return [
item.tag_name || '', // 分类
item.standard_question,
item.similar_questions.join('##'),
item.negative_questions.join('##'),
item.answers.join('##'),
'FALSE', // 是否全部回复
'FALSE', // 是否停用
'FALSE', // 是否禁止被推荐
]
})
const csvContent = [
headers.join('\t'), // 使用制表符分隔
...rows.map((row) => row.map((cell) => {
// 如果包含制表符、换行符或引号,需要用引号包裹
if (cell.includes('\t') || cell.includes('\n') || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`
}
return cell
}).join('\t')),
].join('\n')
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'faq_example.csv'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// 下载 Excel 示例
const downloadExcelExample = () => {
const worksheet = XLSX.utils.json_to_sheet(
exampleData.map((item) => ({
'分类(必填)': item.tag_name || '',
'问题(必填)': item.standard_question,
'相似问题(选填-多个用##分隔)': item.similar_questions.join('##'),
'反例问题(选填-多个用##分隔)': item.negative_questions.join('##'),
'机器人回答(必填-多个用##分隔)': item.answers.join('##'),
'是否全部回复(选填-默认FALSE)': 'FALSE',
'是否停用(选填-默认FALSE)': 'FALSE',
'是否禁止被推荐(选填-默认False 可被推荐)': 'FALSE',
})),
)
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, 'FAQ')
XLSX.writeFile(workbook, 'faq_example.xlsx')
}
// 导出 FAQ 数据为 CSV
const exportLoading = ref(false)
const handleExportCSV = async () => {
if (!props.kbId) {
MessagePlugin.warning(t('knowledgeBase.selectKnowledgeBase') || '请先选择知识库')
return
}
exportLoading.value = true
try {
const blob = await exportFAQEntries(props.kbId)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `faq_export_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
MessagePlugin.success(t('knowledgeEditor.faqExport.exportSuccess') || '导出成功')
} catch (error: any) {
console.error('Export failed:', error)
MessagePlugin.error(t('knowledgeEditor.faqExport.exportFailed') || '导出失败')
} finally {
exportLoading.value = false
}
}
watch(
() => props.kbId,
async (newKbId) => {
currentPage = 1
hasMore.value = true
selectedTagId.value = UNTAGGED_FILTER
overallFAQTotal.value = 0 // Reset to trigger re-fetch
cancelCreateTag()
cancelEditTag()
tagSearchQuery.value = ''
if (!newKbId) {
kbInfo.value = null
// kbId变化时,清除之前的任务状态
stopPolling()
importState.taskId = null
importState.taskStatus = null
clearTaskIdFromStorage()
return
}
const info = await loadKnowledgeInfo(newKbId)
if (!info || info.type !== 'faq') {
return
}
loadEntries()
loadTags(true)
// 恢复导入任务状态(如果存在)
await restoreImportTask()
},
{ immediate: true },
)
watch(selectedTagId, (newVal, oldVal) => {
if (oldVal === undefined) return
if (newVal !== oldVal) {
currentPage = 1
entries.value = []
selectedRowKeys.value = []
loadEntries()
}
})
watch(tagSearchQuery, (newVal, oldVal) => {
if (newVal === oldVal) return
if (tagSearchDebounce) {
clearTimeout(tagSearchDebounce)
}
tagSearchDebounce = window.setTimeout(() => {
loadTags(true)
}, 300)
})
// 监听FAQ搜索关键词变化
watch(entrySearchKeyword, (newVal, oldVal) => {
if (newVal === oldVal) return
if (entrySearchDebounce) {
clearTimeout(entrySearchDebounce)
}
entrySearchDebounce = window.setTimeout(() => {
loadEntries()
}, 300)
})
const handleSearch = async () => {
if (!searchForm.query.trim()) {
MessagePlugin.warning(t('knowledgeEditor.faq.queryPlaceholder'))
return
}
searching.value = true
hasSearched.value = true
try {
const res = await searchFAQEntries(props.kbId, {
query_text: searchForm.query.trim(),
vector_threshold: searchForm.vectorThreshold,
match_count: searchForm.matchCount,
})
const results = (res.data || []).map((entry: FAQEntry) => ({
...entry,
similarCollapsed: true, // 相似问默认折叠
negativeCollapsed: true, // 反例默认折叠
answersCollapsed: true, // 答案默认折叠
expanded: false,
})) as FAQEntry[]
// 按score从大到小排序
searchResults.value = results.sort((a, b) => (b.score || 0) - (a.score || 0))
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'))
searchResults.value = []
} finally {
searching.value = false
}
}
const getMatchTypeLabel = (matchType?: string) => {
if (!matchType) return ''
if (matchType === 'embedding') {
return t('knowledgeEditor.faq.matchTypeEmbedding')
}
if (matchType === 'keywords') {
return t('knowledgeEditor.faq.matchTypeKeywords')
}
return matchType
}
const toggleResult = (result: FAQEntry) => {
result.expanded = !result.expanded
}
// 防抖函数
let arrangeCardsTimer: ReturnType<typeof setTimeout> | null = null
const debounceArrangeCards = (delay = 100) => {
if (arrangeCardsTimer) {
clearTimeout(arrangeCardsTimer)
}
arrangeCardsTimer = setTimeout(() => {
arrangeCards()
arrangeCardsTimer = null
}, delay)
}
// 瀑布流布局函数 - 优化版本,避免闪烁
const arrangeCards = () => {
if (!cardListRef.value) return
const cards = cardListRef.value.querySelectorAll('.faq-card') as NodeListOf<HTMLElement>
if (cards.length === 0) return
// 获取容器宽度和列数
const containerWidth = cardListRef.value.offsetWidth
const gap = 12 // 与 CSS gap 保持一致
let columnCount = 1
// 根据容器宽度计算列数(增加每行的卡片数量)
if (containerWidth >= 2560) columnCount = 12
else if (containerWidth >= 1920) columnCount = 10
else if (containerWidth >= 1536) columnCount = 8
else if (containerWidth >= 1280) columnCount = 6
else if (containerWidth >= 1024) columnCount = 5
else if (containerWidth >= 768) columnCount = 4
else if (containerWidth >= 640) columnCount = 3
const columnWidth = (containerWidth - (gap * (columnCount - 1))) / columnCount
// 初始化每列的高度数组
const columnHeights = new Array(columnCount).fill(0)
// 使用 requestAnimationFrame 优化性能
requestAnimationFrame(() => {
// 先设置宽度,保持当前位置不变
cards.forEach((card) => {
// 确保卡片是绝对定位
if (card.style.position !== 'absolute') {
card.style.position = 'absolute'
}
// 设置宽度以便正确计算高度
card.style.width = `${columnWidth}px`
})
// 等待浏览器重新计算布局
requestAnimationFrame(() => {
// 计算所有卡片的高度(不改变位置)
const cardHeights: number[] = []
cards.forEach((card) => {
const height = card.offsetHeight || card.getBoundingClientRect().height
cardHeights.push(height)
})
// 计算新位置
const newPositions: Array<{ top: number; left: number }> = []
cardHeights.forEach((height) => {
const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights))
const top = columnHeights[shortestColumnIndex]
const left = shortestColumnIndex * (columnWidth + gap)
newPositions.push({ top, left })
columnHeights[shortestColumnIndex] += height + gap
})
// 批量更新所有卡片位置,使用CSS过渡实现平滑移动
cards.forEach((card, index) => {
const { top, left } = newPositions[index]
const currentTop = parseFloat(card.style.top) || 0
const currentLeft = parseFloat(card.style.left) || 0
// 如果位置发生变化,添加过渡效果
if (Math.abs(currentTop - top) > 1 || Math.abs(currentLeft - left) > 1) {
// 使用 will-change 提示浏览器优化
card.style.willChange = 'top, left'
card.style.transition = 'top 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
}
card.style.position = 'absolute'
card.style.top = `${top}px`
card.style.left = `${left}px`
card.style.width = `${columnWidth}px`
})
// 设置容器高度
const maxHeight = Math.max(...columnHeights)
if (cardListRef.value) {
cardListRef.value.style.height = `${maxHeight}px`
cardListRef.value.style.position = 'relative'
}
// 动画完成后移除过渡和 will-change,避免影响后续交互
setTimeout(() => {
cards.forEach((card) => {
card.style.transition = ''
card.style.willChange = ''
})
}, 300)
})
})
}
// 监听窗口大小变化(使用防抖)
let resizeTimer: ReturnType<typeof setTimeout> | null = null
const handleResize = () => {
if (resizeTimer) {
clearTimeout(resizeTimer)
}
resizeTimer = setTimeout(() => {
arrangeCards()
// 窗口变大时可能需要加载更多,延迟执行确保布局完成
setTimeout(() => {
checkAndLoadMore()
}, 350)
resizeTimer = null
}, 150)
}
onMounted(async () => {
loadKnowledgeList()
window.addEventListener('resize', handleResize)
window.addEventListener('faqMenuAction', handleFaqMenuAction as EventListener)
// 如果已有kbId,恢复导入任务状态
if (props.kbId) {
await restoreImportTask()
}
// 主动触发一次选中数量事件,确保左侧菜单能接收到初始状态
nextTick(() => {
const count = selectedRowKeys.value.length
const selectedEntries = entries.value.filter(entry => selectedRowKeys.value.includes(entry.id))
const enabledCount = selectedEntries.filter(entry => entry.is_enabled !== false).length
const disabledCount = count - enabledCount
window.dispatchEvent(new CustomEvent('faqSelectionChanged', {
detail: {
count,
enabledCount,
disabledCount
}
}))
})
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('faqMenuAction', handleFaqMenuAction as EventListener)
if (arrangeCardsTimer) {
clearTimeout(arrangeCardsTimer)
}
if (resizeTimer) {
clearTimeout(resizeTimer)
}
})
// 监听 entries 变化,重新布局
watch(() => entries.value.length, () => {
nextTick(() => {
arrangeCards()
})
})
// 监听折叠状态变化,重新布局(使用防抖和动画完成后的回调)
watch(() => entries.value.map(e => ({
id: e.id,
similarCollapsed: e.similarCollapsed,
negativeCollapsed: e.negativeCollapsed,
answersCollapsed: e.answersCollapsed
})), () => {
// 使用 nextTick 确保 DOM 更新
nextTick(() => {
// 等待一个渲染帧,让高度变化生效
requestAnimationFrame(() => {
// 再等待一个渲染帧,确保高度计算准确
requestAnimationFrame(() => {
// 等待 Transition 动画完成后再布局(slide-down 动画时长约 200ms)
// 使用防抖避免频繁调用
debounceArrangeCards(250)
})
})
})
}, { deep: true })
</script>
<style lang="less">
.tag-more-popup {
z-index: 99 !important;
.t-popup__content {
padding: 4px 0 !important;
margin-top: 4px !important;
min-width: 120px;
}
}
/* 面包屑下拉菜单优化 */
.t-popup__content {
.t-dropdown__menu {
background: #ffffff;
border: 1px solid #e7e9eb;
border-radius: 10px;
box-shadow: 0 6px 28px rgba(15, 23, 42, 0.08);
padding: 6px;
min-width: 150px;
max-width: 200px;
}
.t-dropdown__item {
padding: 8px 12px;
border-radius: 6px;
margin: 2px 0;
transition: all 0.12s ease;
font-size: 13px;
color: #0f172a;
cursor: pointer;
min-width: auto !important;
max-width: 100% !important;
display: flex !important;
align-items: center;
width: 100%;
&:hover {
background: #f6f8f7;
color: #10b981;
}
.t-dropdown__item-icon {
flex-shrink: 0;
margin-right: 8px;
color: inherit;
display: flex;
align-items: center;
.t-icon {
font-size: 16px;
}
}
.t-dropdown__item-text {
color: inherit !important;
font-size: 13px !important;
line-height: 1.5 !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex: 1;
min-width: 0;
display: block;
}
}
}
</style>
<style scoped lang="less">
.faq-manager {
display: flex;
flex-direction: column;
height: 100%;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.faq-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.faq-main {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
}
.faq-tag-panel {
width: 230px;
background: #fff;
border: 1px solid #e7ebf0;
border-radius: 12px;
padding: 16px;
flex-shrink: 0;
display: flex;
flex-direction: column;
max-height: 100%;
min-height: 0;
overflow: hidden;
// t-loading 包裹容器需要撑满剩余空间
> .t-loading__parent,
> .t-loading {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
color: #1d2129;
.sidebar-title {
display: flex;
align-items: baseline;
gap: 4px;
font-weight: 600;
.sidebar-count {
font-size: 12px;
color: #86909c;
}
}
.sidebar-actions {
display: flex;
gap: 8px;
color: #c9ced6;
.create-tag-btn {
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: #00a870;
line-height: 1;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: #f3f5f7;
color: #05a04f;
}
}
.create-tag-plus {
line-height: 1;
}
.sidebar-action-icon {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: #f3f5f7;
color: #00a870;
}
}
}
}
.tag-search-bar {
margin-bottom: 12px;
:deep(.t-input) {
font-size: 12px;
background-color: #f7f9fc;
border-color: #e5e9f2;
}
}
.faq-tag-list {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
.tag-loading-more {
padding: 12px 0;
display: flex;
justify-content: center;
flex-shrink: 0;
}
.faq-tag-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
color: #4e5969;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
.faq-tag-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
.t-icon {
flex-shrink: 0;
color: #86909c;
font-size: 16px;
transition: color 0.2s ease;
}
}
.tag-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
}
.faq-tag-right {
display: flex;
align-items: center;
gap: 6px;
margin-left: 8px;
min-width: 0;
flex-shrink: 0;
}
.faq-tag-count {
font-size: 12px;
color: #86909c;
font-weight: 500;
padding: 2px 6px;
border-radius: 10px;
background: #f7f9fc;
transition: all 0.2s ease;
}
&:hover {
background: #f7f9fc;
color: #1d2129;
.faq-tag-left .t-icon {
color: #4e5969;
}
.faq-tag-count {
background: #e5e9f2;
color: #4e5969;
}
}
&.active {
background: #e6f7ec;
color: #00a870;
font-weight: 500;
.faq-tag-left .t-icon {
color: #00a870;
}
.faq-tag-count {
background: #b8f0d3;
color: #00a870;
font-weight: 600;
}
&:hover {
background: #d4f4e3;
}
}
&.editing {
background: transparent;
border: none;
}
&.tag-editing {
cursor: default;
padding-right: 8px;
background: transparent;
border: none;
.tag-edit-input {
flex: 1;
}
}
&.tag-editing .tag-edit-input {
width: 100%;
}
.tag-inline-actions {
display: flex;
gap: 4px;
margin-left: auto;
:deep(.t-button) {
padding: 0 4px;
height: 24px;
}
:deep(.tag-action-btn) {
border-radius: 4px;
transition: all 0.2s ease;
.t-icon {
font-size: 14px;
}
}
:deep(.tag-action-btn.confirm) {
background: #eefcf5;
color: #059669;
&:hover {
background: #d9f7e9;
color: #047857;
}
}
:deep(.tag-action-btn.cancel) {
background: #f9fafb;
color: #6b7280;
&:hover {
background: #f3f4f6;
color: #4b5563;
}
}
}
.tag-edit-input {
flex: 1;
min-width: 0;
max-width: 100%;
:deep(.t-input) {
font-size: 12px;
background-color: transparent;
border: none;
border-bottom: 1px solid #d0d5dd;
border-radius: 0;
box-shadow: none;
padding-left: 0;
padding-right: 0;
}
:deep(.t-input__wrap) {
background-color: transparent;
border: none;
border-bottom: 1px solid #d0d5dd;
border-radius: 0;
box-shadow: none;
}
:deep(.t-input__inner) {
padding-left: 0;
padding-right: 0;
color: #1d2129;
caret-color: #1d2129;
}
:deep(.t-input:hover),
:deep(.t-input.t-is-focused),
:deep(.t-input__wrap:hover),
:deep(.t-input__wrap.t-is-focused) {
border-bottom-color: #00a870;
}
}
.tag-more-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #86909c;
transition: all 0.2s ease;
opacity: 0.6;
&:hover {
background: #f3f5f7;
color: #4e5969;
opacity: 1;
}
}
.tag-more {
display: flex;
align-items: center;
}
.tag-more-placeholder {
width: 24px; // Same width as tag-more-btn
height: 24px;
flex-shrink: 0;
}
}
.tag-empty-state {
text-align: center;
padding: 12px 8px;
color: #a1a7b3;
font-size: 12px;
}
}
}
.faq-card-area {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.faq-search-bar {
padding: 0px 0px 10px 0px;
flex-shrink: 0;
:deep(.t-input) {
font-size: 13px;
background-color: #f7f9fc;
border-color: #e5e9f2;
border-radius: 6px;
&:hover,
&:focus,
&.t-is-focused {
background-color: #fff;
border-color: #00a870;
}
}
}
:deep(.tag-menu) {
display: flex;
flex-direction: column;
}
:deep(.tag-menu-item) {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
color: #000000e6;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 400;
.menu-icon {
margin-right: 8px;
font-size: 16px;
}
&:hover {
background: #f5f5f5;
color: #000000e6;
}
&.danger {
color: #000000e6;
&:hover {
background: #fff1f0;
color: #fa5151;
.menu-icon {
color: #fa5151;
}
}
}
}
.faq-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 0 0 16px;
margin-bottom: 20px;
border-bottom: 1px solid #e7ebf0;
flex-shrink: 0;
}
// 导入进度条样式(显示在列表页面顶部)
.faq-import-progress-bar {
margin-bottom: 16px;
background: linear-gradient(135deg, #f8fffe 0%, #f5fff9 100%);
border: 1px solid #d4f0e0;
border-radius: 10px;
padding: 14px 18px;
box-shadow: 0 2px 12px rgba(0, 168, 112, 0.08);
.progress-bar-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.progress-bar-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #000000e6;
.progress-left {
display: flex;
align-items: center;
gap: 10px;
}
.progress-right {
display: flex;
align-items: center;
gap: 16px;
}
.progress-icon {
flex-shrink: 0;
&.icon-loading {
animation: rotate 1s linear infinite;
color: #00a870;
}
&.icon-success {
color: #00a870;
}
&.icon-error {
color: #fa5151;
}
}
.progress-title {
font-weight: 600;
font-size: 14px;
color: #1d2129;
}
.progress-count {
color: #4e5969;
font-size: 13px;
font-weight: 500;
background: rgba(0, 168, 112, 0.1);
padding: 2px 10px;
border-radius: 12px;
}
.progress-close-btn {
flex-shrink: 0;
padding: 4px;
margin-left: 4px;
border-radius: 4px;
&:hover {
background: rgba(0, 0, 0, 0.06);
}
}
}
.progress-bar {
margin: 0;
width: 100%;
:deep(.t-progress) {
width: 100%;
}
:deep(.t-progress__bar) {
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(0, 168, 112, 0.15);
}
:deep(.t-progress__inner) {
border-radius: 4px;
}
}
.progress-error {
margin: 0;
font-size: 13px;
color: #fa5151;
line-height: 1.5;
background: rgba(250, 81, 81, 0.08);
padding: 8px 12px;
border-radius: 6px;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.faq-header-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.faq-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.faq-breadcrumb {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
.breadcrumb-link {
border: none;
background: transparent;
padding: 4px 8px;
margin: -4px -8px;
font: inherit;
color: #4e5969;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 6px;
transition: all 0.12s ease;
&:hover:not(:disabled) {
color: #10b981;
background: #f6f8f7;
}
&:disabled {
cursor: not-allowed;
color: #c9ced6;
}
&.dropdown {
padding-right: 6px;
:deep(.t-icon) {
font-size: 14px;
transition: transform 0.12s ease;
}
&:hover:not(:disabled) {
:deep(.t-icon) {
transform: translateY(1px);
}
}
}
}
.breadcrumb-separator {
font-size: 14px;
color: #c9ced6;
}
.breadcrumb-current {
color: #1d2129;
font-weight: 600;
}
h2 {
margin: 0;
color: #1d2129;
font-family: "PingFang SC";
font-size: 24px;
font-weight: 600;
line-height: 32px;
}
.faq-subtitle {
margin: 0;
color: #00000099;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.tag-filter-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
.tag-filter-label {
color: #6B7280;
font-size: 14px;
}
}
.kb-settings-button {
width: 30px;
height: 30px;
border: none;
border-radius: 50%;
background: #f5f6f8;
display: inline-flex;
align-items: center;
justify-content: center;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
&:hover:not(:disabled) {
background: #e6f7ec;
color: #07c05f;
}
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
:deep(.t-icon) {
font-size: 18px;
}
}
// 滚动容器
.faq-scroll-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
// 卡片列表样式 - 使用绝对定位实现瀑布流,下一行补齐上一行空缺
.faq-card-list {
position: relative;
width: 100%;
min-width: 0;
}
.faq-card {
border: 1px solid #E7E7E7;
border-radius: 12px;
background: #fff;
padding: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
max-width: 100%;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
box-sizing: border-box;
height: fit-content; // 高度根据内容自适应
// 注意:top 和 left 的 transition 由 JS 动态控制,避免与布局动画冲突
&:hover {
border-color: #07C05F;
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.1);
}
&.selected {
border-color: #07C05F;
background: #F0FDF4;
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.15);
}
}
.faq-card-header {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid #F3F4F6;
position: relative;
}
.faq-header-top {
display: flex;
align-items: flex-start;
gap: 12px;
}
.faq-card-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-shrink: 0;
}
.faq-header-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding-top: 6px;
border-top: 1px dashed #F3F4F6;
}
.faq-meta-item {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: #F9FAFB;
border: 1px solid #EEF2F7;
.meta-label {
font-size: 12px;
color: #6B7280;
font-weight: 500;
}
.meta-value {
font-size: 13px;
color: #111827;
font-weight: 600;
}
}
.faq-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 16px;
margin: 0 -16px -12px;
background: rgba(48, 50, 54, 0.02);
border-top: 1px solid #f5f5f5;
flex-wrap: nowrap;
}
.faq-card-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.status-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: #F9FAFB;
border: 1px solid #EEF2F7;
font-size: 12px;
color: #4B5563;
font-family: "PingFang SC";
.status-icon {
font-size: 14px;
color: #9CA3AF;
&.warning {
color: #F97316;
}
&.success {
color: #10B981;
}
}
}
.status-item-compact {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 4px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
.status-icon {
font-size: 16px;
flex-shrink: 0;
&.warning {
color: #F97316;
}
&.success {
color: #10B981;
}
}
:deep(.t-switch) {
flex-shrink: 0;
}
}
.faq-card-tag {
display: flex;
align-items: center;
justify-content: flex-start;
flex: 1;
min-width: 0;
:deep(.t-tag) {
display: inline-flex;
align-items: center;
cursor: pointer;
max-width: 140px;
height: 22px;
border-radius: 4px;
border-color: #e5e7eb;
color: #00000066;
padding: 0 8px;
background: #3032360f;
font-size: 12px;
font-weight: 400;
font-family: "PingFang SC";
transition: all 0.2s ease;
&:hover {
border-color: #07c05f;
color: #059669;
background: #ecfdf5;
}
}
}
.faq-tag-chip {
display: inline-flex;
align-items: center;
cursor: pointer;
.tag-text {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 400;
color: #00000066;
}
}
.card-more-btn {
display: flex;
width: 28px;
height: 28px;
justify-content: center;
align-items: center;
border-radius: 6px;
cursor: pointer;
flex-shrink: 0;
opacity: 0.6;
&:hover {
background: #F3F4F6;
opacity: 1;
}
&.mobile {
display: none;
}
.more-icon {
width: 16px;
height: 16px;
}
}
.card-menu {
display: flex;
flex-direction: column;
}
.card-menu-item {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
color: #000000e6;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 400;
.menu-icon {
margin-right: 8px;
font-size: 16px;
flex-shrink: 0;
}
&:hover {
background: #f5f5f5;
color: #000000e6;
}
&.danger {
color: #000000e6;
&:hover {
background: #fff1f0;
color: #fa5151;
.menu-icon {
color: #fa5151;
}
}
}
}
.faq-question {
flex: 1;
color: #111827;
font-family: "PingFang SC";
font-size: 15px;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
min-width: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.faq-card-body {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
overflow: hidden;
contain: layout; // 优化渲染性能
}
.faq-section {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
overflow: hidden;
.faq-section-label {
color: #6B7280;
font-family: "PingFang SC";
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 2px;
&::before {
content: '';
width: 3px;
height: 12px;
background: #07C05F;
border-radius: 2px;
flex-shrink: 0;
}
&.clickable {
cursor: pointer;
user-select: none;
padding: 2px 0;
border-radius: 4px;
&:hover {
color: #111827;
background: #F9FAFB;
padding-left: 4px;
padding-right: 4px;
margin-left: -4px;
margin-right: -4px;
}
}
.collapse-icon {
font-size: 14px;
color: #9CA3AF;
flex-shrink: 0;
margin-left: auto; // 让箭头靠右对齐
}
.section-count {
color: #9CA3AF;
font-weight: 400;
margin-left: 4px;
}
}
&.answers .faq-section-label::before {
background: #07C05F;
}
&.similar .faq-section-label::before {
background: #3B82F6;
}
&.negative .faq-section-label::before {
background: #F59E0B;
}
}
.faq-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 20px;
min-width: 0;
width: 100%;
overflow: hidden;
contain: layout style paint; // 优化渲染性能
// 确保每个标签都有最大宽度限制
> * {
max-width: 100%;
min-width: 0;
flex: 0 1 auto;
}
// 当标签单独一行时,限制最大宽度
> *:first-child:last-child {
max-width: 100%;
}
}
.question-tag {
font-size: 12px;
padding: 4px 10px;
max-width: 100%;
min-width: 0;
border-radius: 6px;
font-family: "PingFang SC";
flex: 0 1 auto;
:deep(.t-tag) {
max-width: 100% !important;
min-width: 0 !important;
width: auto !important;
display: inline-flex !important;
align-items: center;
vertical-align: middle;
overflow: hidden !important;
box-sizing: border-box;
background: #F9FAFB;
border-color: #E5E7EB;
color: #374151;
}
// 针对TDesign tag内部的span元素
:deep(.t-tag span),
:deep(.t-tag > span) {
display: block !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
max-width: 100% !important;
width: auto !important;
line-height: 1.4;
min-width: 0 !important;
}
}
// 确保 tag 本身不会超出容器
.faq-tags :deep(.t-tag) {
max-width: 100%;
min-width: 0;
flex-shrink: 1;
}
.faq-tags :deep(.faq-tag-wrapper) {
max-width: 100%;
min-width: 0;
flex-shrink: 1;
}
.empty-tip {
color: #9CA3AF;
font-size: 12px;
font-style: italic;
padding: 8px 0;
font-family: "PingFang SC";
}
.faq-load-more,
.faq-no-more {
display: flex;
justify-content: center;
align-items: center;
padding: 24px 16px;
color: #6B7280;
font-size: 13px;
font-family: "PingFang SC";
}
.faq-no-more {
color: #9CA3AF;
font-style: italic;
}
// 空状态样式
.faq-empty-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
padding: 60px 20px;
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
max-width: 400px;
}
.empty-icon {
color: #D1D5DB;
opacity: 0.6;
}
.empty-text {
color: #111827;
font-family: "PingFang SC";
font-size: 18px;
font-weight: 600;
line-height: 28px;
}
.empty-desc {
color: #6B7280;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 400;
line-height: 22px;
}
}
// 导入对话框样式 - 与创建知识库弹窗风格一致
.faq-import-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(4px);
}
.faq-import-modal {
position: relative;
width: 100%;
max-width: 600px;
max-height: 90vh;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 6px 28px rgba(15, 23, 42, 0.08);
overflow: hidden;
display: flex;
flex-direction: column;
.close-btn {
position: absolute;
top: 20px;
right: 20px;
width: 32px;
height: 32px;
border: none;
background: #f5f5f5;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #666;
transition: all 0.2s ease;
z-index: 10;
&:hover {
background: #e5e5e5;
color: #000;
}
}
}
.faq-import-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.faq-import-header {
padding: 24px 24px 16px;
border-bottom: 1px solid #e5e5e5;
flex-shrink: 0;
.import-title {
margin: 0;
font-family: "PingFang SC";
font-size: 18px;
font-weight: 600;
color: #000000e6;
}
}
.faq-import-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
min-height: 0;
max-height: calc(90vh - 140px); // 减去 header 和 footer 的高度
// 自定义滚动条
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #d0d0d0;
border-radius: 3px;
transition: background 0.2s;
&:hover {
background: #07C05F;
}
}
}
.faq-import-footer {
padding: 16px 24px;
border-top: 1px solid #e5e5e5;
display: flex;
justify-content: flex-end;
gap: 12px;
flex-shrink: 0;
}
// 导入表单项
.import-form-item {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
// 文件标签行
.file-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
gap: 12px;
}
// 下载示例按钮
.download-example-btn {
display: flex;
align-items: center;
gap: 6px;
font-family: "PingFang SC";
font-size: 13px;
font-weight: 500;
padding: 6px 14px;
border-radius: 6px;
border: 1px solid #E7E7E7;
background: #ffffff;
color: #333333;
transition: all 0.2s ease;
cursor: pointer;
white-space: nowrap;
&:hover {
border-color: #07C05F;
color: #07C05F;
background: #f0fdf4;
}
&:active {
background: #e0f9ea;
}
:deep(.t-icon) {
font-size: 16px;
}
}
// 导入表单标签
.import-form-label {
display: block;
margin-bottom: 0;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 500;
color: #333333;
letter-spacing: -0.2px;
flex: 1;
&.required::after {
content: '*';
color: #FA5151;
margin-left: 4px;
font-weight: 600;
}
}
// 单选按钮组样式 - 符合项目主题风格
:deep(.import-radio-group) {
.t-radio-group--filled {
background: #f5f5f5;
border-radius: 6px;
padding: 2px;
}
.t-radio-button {
font-family: "PingFang SC";
font-size: 14px;
border-color: #d9d9d9;
transition: all 0.2s ease;
&:hover:not(.t-is-disabled) {
border-color: #07c05f;
color: #07c05f;
}
&.t-is-checked {
background: #07c05f;
border-color: #07c05f;
color: #fff;
font-weight: 500;
&:hover:not(.t-is-disabled) {
background: #05a04f;
border-color: #05a04f;
color: #fff;
}
}
}
}
// 文件上传包装器
.file-upload-wrapper {
width: 100%;
}
// 隐藏的文件输入
.file-input-hidden {
position: absolute;
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
}
// 文件上传区域
.file-upload-area {
position: relative;
width: 100%;
min-height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #07C05F;
background: #f0fdf4;
}
&.has-file {
border-color: #07C05F;
background: #f0fdf4;
border-style: solid;
}
}
// 文件上传内容
.file-upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.upload-icon {
color: #07C05F;
transition: transform 0.2s ease;
}
.file-upload-area:hover .upload-icon {
transform: translateY(-2px);
}
.upload-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.upload-primary-text {
font-family: "PingFang SC";
font-size: 14px;
font-weight: 500;
color: #333333;
}
.upload-secondary-text {
font-family: "PingFang SC";
font-size: 12px;
color: #666666;
}
.upload-file-name {
font-family: "PingFang SC";
font-size: 14px;
font-weight: 500;
color: #07C05F;
word-break: break-all;
}
// 导入表单提示
.import-form-tip {
margin-top: 8px;
font-family: "PingFang SC";
font-size: 12px;
color: #00000066;
line-height: 18px;
}
// 预览区域
.import-preview {
margin-top: 20px;
padding: 16px;
background: #fafafa;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.preview-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
}
.preview-icon {
color: #07C05F;
flex-shrink: 0;
}
.preview-title {
font-family: "PingFang SC";
font-size: 14px;
font-weight: 500;
color: #333333;
}
.preview-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.preview-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
border-color: #07C05F;
box-shadow: 0 2px 4px rgba(7, 192, 95, 0.08);
}
}
.preview-index {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #07C05F 0%, #05a04f 100%);
color: #ffffff;
border-radius: 4px;
font-family: "PingFang SC";
font-size: 12px;
font-weight: 600;
}
.preview-question {
flex: 1;
font-family: "PingFang SC";
font-size: 13px;
color: #333333;
line-height: 1.5;
word-break: break-word;
}
.preview-more {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
font-family: "PingFang SC";
font-size: 12px;
color: #666666;
text-align: center;
}
// 响应式布局由 JavaScript 动态计算,这里不需要媒体查询
// 卡片菜单弹窗样式
:deep(.faq-card-popup) {
z-index: 99 !important;
.t-popup__content {
padding: 4px 0 !important;
margin-top: 4px !important;
min-width: 120px;
}
}
// FAQ 编辑器抽屉样式
:deep(.faq-editor-drawer) {
.t-drawer__body {
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
height: 100%;
}
.t-drawer__header {
padding: 20px 24px;
border-bottom: 1px solid #e5e5e5;
font-family: "PingFang SC";
font-size: 18px;
font-weight: 600;
color: #000000e6;
}
.t-drawer__footer {
padding: 16px 24px;
border-top: 1px solid #e5e5e5;
}
}
.faq-editor-drawer-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
// 自定义滚动条
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #d0d0d0;
border-radius: 3px;
transition: background 0.2s;
&:hover {
background: #07C05F;
}
}
.editor-form {
width: 100%;
}
}
.faq-editor-drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
// 全宽输入框包装器 - 统一样式
.full-width-input-wrapper {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
.full-width-input {
flex: 1;
min-width: 0;
}
.full-width-textarea {
flex: 1;
min-width: 0;
:deep(.t-textarea__inner) {
min-height: 80px;
}
}
// textarea需要顶部对齐
&.textarea-wrapper {
align-items: flex-start;
}
.add-item-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
font-family: "PingFang SC";
transition: all 0.2s ease;
border-radius: 8px;
}
:deep(.add-item-btn) {
background: #07C05F !important;
border: 1px solid #07C05F !important;
border-radius: 8px !important;
color: #ffffff !important;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(:disabled) {
background: #05a04f !important;
border-color: #05a04f !important;
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.3);
}
&:active:not(:disabled) {
background: #048a42 !important;
border-color: #048a42 !important;
transform: scale(0.98);
}
&:disabled {
background: #E5E7EB !important;
border-color: #E5E7EB !important;
color: #9CA3AF !important;
cursor: not-allowed;
opacity: 0.6;
}
.t-icon {
font-size: 16px;
}
}
}
.textarea-container {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.item-count {
font-size: 13px;
color: #6B7280;
font-family: "PingFang SC";
font-weight: 500;
text-align: right;
padding-right: 40px;
line-height: 1;
}
.item-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 8px;
}
.item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #ffffff;
border: 1px solid #E7E7E7;
border-radius: 8px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
position: relative;
&.answer-row {
align-items: flex-start;
padding: 12px 14px;
}
&:hover {
background: #fafafa;
border-color: #07C05F;
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.12);
transform: translateY(-1px);
}
&.negative {
background: #FFFBEB;
border-color: #FDE68A;
&:hover {
background: #FEF3C7;
border-color: #FCD34D;
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.15);
}
}
.item-content {
flex: 1;
font-size: 14px;
line-height: 1.6;
color: #111827;
font-family: "PingFang SC";
white-space: pre-wrap;
word-break: break-word;
padding: 0;
font-weight: 400;
}
.remove-item-btn {
flex-shrink: 0;
color: #9CA3AF;
padding: 0;
width: 24px;
height: 24px;
min-width: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
background: transparent;
border: none;
cursor: pointer;
&:hover {
color: #EF4444;
background: #FEE2E2;
}
&:active {
background: #FECACA;
}
:deep(.t-icon) {
font-size: 14px;
}
}
&.answer-row .remove-item-btn {
margin-top: 0;
}
}
.form-tip {
margin-top: 6px;
font-size: 12px;
color: #00000066;
font-family: "PingFang SC";
}
// FAQ编辑器表单样式 - 完全参考设置页面
.faq-editor-form {
width: 100%;
// 隐藏Form的默认结构
:deep(.t-form__label) {
display: none !important;
width: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
:deep(.t-form__controls) {
margin-left: 0 !important;
width: 100% !important;
}
:deep(.t-form__controls-content) {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
display: block !important;
}
:deep(.t-form-item) {
margin-bottom: 0 !important;
padding: 0 !important;
}
}
.settings-group {
display: flex;
flex-direction: column;
gap: 0;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
border-bottom: 1px solid #e5e7eb;
&:last-child {
border-bottom: none;
}
&.vertical {
flex-direction: column;
gap: 12px;
.setting-control {
width: 100%;
max-width: 100%;
}
}
// 主要字段(标准问、答案)的强调样式
&.setting-row-primary {
padding: 20px 0;
padding-left: 12px;
position: relative;
// 第一个(标准问)去掉顶部间距
&:first-child {
padding-top: 0;
}
// 左侧颜色标记(标准问和答案都用绿色)
&::before {
content: '';
position: absolute;
left: 0;
top: 20px;
width: 3px;
height: calc(100% - 40px);
background: #07C05F;
border-radius: 0 2px 2px 0;
}
&:first-child::before {
top: 0;
height: calc(100% - 20px);
}
}
// 可选字段(相似问、反例)的次要样式
&.setting-row-optional {
padding-left: 12px;
position: relative;
// 左侧颜色标记
&::before {
content: '';
position: absolute;
left: 0;
top: 20px;
width: 3px;
height: calc(100% - 40px);
border-radius: 0 2px 2px 0;
}
.setting-info {
.optional-label {
color: #333333;
font-weight: 500;
}
.optional-desc {
color: #666666;
}
}
}
// 相似问的蓝色标记
&.setting-row-similar::before {
background: #3B82F6;
}
// 反例的橙色标记
&.setting-row-negative::before {
background: #F59E0B;
}
// 答案去掉底部边框
&.setting-row-answer {
border-bottom: none;
}
}
.setting-info {
flex: 1;
max-width: 65%;
padding-right: 24px;
label {
font-size: 15px;
font-weight: 500;
color: #333333;
display: block;
margin-bottom: 4px;
}
.required-label {
font-size: 15px;
font-weight: 600;
color: #333333;
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.required-mark {
color: #FA5151;
font-weight: 600;
font-size: 14px;
}
.optional-label {
font-size: 15px;
font-weight: 600;
color: #333333;
display: block;
margin-bottom: 4px;
}
.desc {
font-size: 13px;
color: #666666;
margin: 0;
line-height: 1.5;
}
.optional-desc {
font-size: 13px;
color: #666666;
}
}
.setting-row.vertical .setting-info {
max-width: 100%;
padding-right: 0;
width: 100%;
}
.setting-control {
flex-shrink: 0;
min-width: 280px;
display: flex;
justify-content: flex-end;
align-items: center;
}
.setting-row.vertical .setting-control {
width: 100%;
max-width: 100%;
min-width: unset;
justify-content: flex-start;
align-items: flex-start;
flex-direction: column;
}
// 垂直布局中的输入框确保全宽
.setting-row.vertical .full-width-input {
width: 100%;
:deep(.t-input__wrap) {
width: 100%;
}
}
.setting-row.vertical .full-width-textarea {
width: 100%;
:deep(.t-textarea) {
width: 100%;
}
}
// Input 组件样式 - 与登录页面一致
:deep(.t-input) {
font-family: "PingFang SC";
font-size: 14px;
border: 1px solid #E7E7E7;
border-radius: 8px;
background: #fff;
transition: all 0.2s ease;
&:hover {
border-color: #07C05F;
}
&:focus-within {
border-color: #07C05F;
box-shadow: 0 0 0 3px rgba(7, 192, 95, 0.1);
}
.t-input__inner {
border: none !important;
box-shadow: none !important;
outline: none !important;
background: transparent;
font-size: 14px;
font-family: "PingFang SC";
padding: 6px 12px;
color: #111827;
&:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
&::placeholder {
color: #9CA3AF;
}
}
.t-input__wrap {
border: none !important;
box-shadow: none !important;
}
}
// Textarea 组件样式
:deep(.t-textarea) {
font-family: "PingFang SC";
font-size: 14px;
border: 1px solid #E7E7E7;
border-radius: 8px;
background: #fff;
transition: all 0.2s ease;
&:hover {
border-color: #07C05F;
}
&:focus-within {
border-color: #07C05F;
box-shadow: 0 0 0 3px rgba(7, 192, 95, 0.1);
}
.t-textarea__inner {
border: none !important;
box-shadow: none !important;
outline: none !important;
background: transparent;
font-size: 14px;
font-family: "PingFang SC";
line-height: 1.6;
resize: vertical;
padding: 6px 12px;
color: #111827;
&:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
&::placeholder {
color: #9CA3AF;
}
}
}
:deep(.t-button--theme-primary) {
background-color: #07c05f;
border-color: #07c05f;
&:hover {
background-color: #05a04f;
border-color: #05a04f;
}
}
// 导入弹窗动画
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active .faq-import-modal,
.modal-leave-active .faq-import-modal,
.modal-enter-active .batch-tag-modal,
.modal-leave-active .batch-tag-modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .faq-import-modal,
.modal-leave-to .faq-import-modal,
.modal-enter-from .batch-tag-modal,
.modal-leave-to .batch-tag-modal {
transform: scale(0.95);
opacity: 0;
}
// Tag 样式优化
.answer-tag {
background: #07c05f1a;
color: #07c05f;
border-color: #07c05f33;
}
.question-tag {
background: #fff;
border-color: #d9d9d9;
color: #00000099;
}
// Search test drawer styles - 与编辑器抽屉风格一致
:deep(.faq-search-drawer) {
.t-drawer__body {
padding: 20px;
display: flex;
flex-direction: column;
height: 100%;
}
.t-drawer__header {
padding: 20px 24px;
border-bottom: 1px solid #e5e5e5;
font-family: "PingFang SC";
font-size: 18px;
font-weight: 600;
color: #000000e6;
}
}
.search-test-content {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 0;
// 隐藏滚动条但保持滚动功能
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE and Edge
&::-webkit-scrollbar {
display: none; // Chrome, Safari, Opera
}
}
.search-form {
flex-shrink: 0;
:deep(.t-form__label) {
display: none !important;
width: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
:deep(.t-form__controls) {
margin-left: 0 !important;
width: 100% !important;
}
:deep(.t-form__controls-content) {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
display: block !important;
}
:deep(.t-form-item) {
margin-bottom: 0 !important;
padding: 0 !important;
}
}
.slider-wrapper {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 2px 0;
}
.search-form .setting-row {
padding: 16px 0;
border-bottom: 1px solid #e5e7eb;
&.search-first-row {
padding-top: 0;
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
.setting-info {
max-width: 100%;
padding-right: 0;
margin-bottom: 8px;
label {
font-size: 14px;
font-weight: 500;
color: #333333;
display: block;
margin-bottom: 4px;
}
.desc {
font-size: 12px;
color: #666666;
margin: 0;
line-height: 1.4;
}
}
.setting-control {
width: 100%;
max-width: 100%;
min-width: unset;
justify-content: flex-start;
align-items: flex-start;
flex-direction: column;
}
}
:deep(.slider-wrapper .t-slider) {
flex: 1;
min-width: 0;
.t-slider__rail {
background: #E7E7E7;
height: 4px;
border-radius: 2px;
}
.t-slider__track {
background: #07C05F;
height: 4px;
border-radius: 2px;
}
.t-slider__button {
width: 16px;
height: 16px;
border: 2px solid #07C05F;
background: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&:hover {
border-color: #05a04f;
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.2);
}
}
}
.slider-value {
flex-shrink: 0;
min-width: 50px;
text-align: right;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 500;
color: #111827;
padding: 4px 8px;
background: #F9FAFB;
border-radius: 6px;
}
.search-button {
height: 36px;
border-radius: 8px;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(7, 192, 95, 0.3);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.search-results {
display: flex;
flex-direction: column;
padding-top: 20px;
padding-left: 0;
width: 100%;
box-sizing: border-box;
}
.results-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
margin-left: 0;
margin-right: 0;
padding-left: 0;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 600;
color: #111827;
flex-shrink: 0;
justify-content: flex-start;
.t-icon {
color: #07C05F;
}
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
color: #6B7280;
font-family: "PingFang SC";
font-size: 14px;
text-align: center;
background: #F9FAFB;
border-radius: 8px;
border: 1px dashed #E7E7E7;
}
.results-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-card {
border: 1px solid #E7E7E7;
border-radius: 8px;
background: #fff;
padding: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
width: 100%;
box-sizing: border-box;
min-width: 0;
overflow: visible;
position: relative;
&:hover {
border-color: #07C05F;
box-shadow: 0 2px 8px rgba(7, 192, 95, 0.12);
}
}
.result-header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 0;
border-bottom: none;
cursor: pointer;
user-select: none;
padding: 4px;
margin: -4px;
border-radius: 6px;
position: relative;
&:hover {
background-color: #F9FAFB;
}
}
.result-card.expanded .result-header {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #E7E7E7;
margin-left: -4px;
margin-right: -4px;
padding-left: 4px;
padding-right: 4px;
}
.result-question-wrapper {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.result-question {
flex: 1;
min-width: 0;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 600;
color: #111827;
line-height: 1.6;
word-break: break-word;
display: flex;
align-items: flex-start;
gap: 6px;
.result-index {
flex-shrink: 0;
color: #07C05F;
font-weight: 600;
}
}
.result-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
flex-shrink: 0;
margin-left: auto;
}
.expand-icon {
flex-shrink: 0;
font-size: 18px;
color: #6B7280;
transition: transform 0.2s ease;
cursor: pointer;
&:hover {
color: #07C05F;
}
}
.score-tag,
.match-type-tag {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
font-family: "PingFang SC";
}
.result-body {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 12px;
margin-top: 0;
border-top: 1px solid #F3F4F6;
position: relative;
width: 100%;
}
// Slide down animation - 优化性能
.slide-down-enter-active {
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
will-change: opacity, transform;
}
.slide-down-leave-active {
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
will-change: opacity, transform;
}
.slide-down-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.slide-down-enter-to {
opacity: 1;
transform: translateY(0);
}
.slide-down-leave-from {
opacity: 1;
transform: translateY(0);
}
.slide-down-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.result-section {
display: flex;
flex-direction: column;
gap: 8px;
}
// 批量分类弹窗样式 - 与导入对话框风格一致
.batch-tag-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(4px);
}
.batch-tag-modal {
position: relative;
width: 100%;
max-width: 480px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 6px 28px rgba(15, 23, 42, 0.08);
overflow: hidden;
display: flex;
flex-direction: column;
.batch-tag-close-btn {
position: absolute;
top: 20px;
right: 20px;
width: 32px;
height: 32px;
border: none;
background: #f5f5f5;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #666;
transition: all 0.2s ease;
z-index: 10;
&:hover {
background: #e5e5e5;
color: #000;
}
}
}
.batch-tag-container {
display: flex;
flex-direction: column;
padding: 24px;
}
.batch-tag-header {
margin-bottom: 24px;
padding-right: 40px;
.batch-tag-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #0f172a;
line-height: 1.4;
}
}
.batch-tag-content {
flex: 1;
min-height: 0;
}
.batch-tag-tip {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
margin-bottom: 20px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
font-size: 14px;
color: #0369a1;
line-height: 1.5;
.tip-icon {
flex-shrink: 0;
margin-top: 2px;
color: #0284c7;
}
}
.batch-tag-form {
margin-top: 0;
:deep(.t-form-item) {
margin-bottom: 0;
}
:deep(.t-form-item__label) {
font-size: 14px;
font-weight: 500;
color: #1d2129;
margin-bottom: 8px;
}
}
.batch-tag-select {
width: 100%;
}
.batch-tag-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
.tag-select-empty {
padding: 8px 12px;
text-align: center;
color: #86909c;
font-size: 14px;
}
.section-label {
font-family: "PingFang SC";
font-size: 12px;
font-weight: 600;
color: #6B7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.result-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
width: 100%;
min-width: 0;
}
:deep(.result-tags .t-tag) {
max-width: 100%;
min-width: 0;
word-break: break-word;
overflow-wrap: break-word;
}
:deep(.result-tags .t-tag__text) {
display: inline-block;
max-width: 100%;
word-break: break-word;
overflow-wrap: break-word;
white-space: normal;
line-height: 1.4;
}
</style>