feat(frontend): unify document tag filter, manage drawer, and detail UX

Split tag filtering from CRUD management on the knowledge base document
page, align copy to "tags" across locales, and polish hover/detail surfaces
with setting-drawer section styling. Return tags from GetKnowledge for the
detail drawer.
This commit is contained in:
wizardchen
2026-06-26 12:23:27 +08:00
committed by lyingbug
parent 96ac1a17a1
commit ecaedf1196
16 changed files with 1847 additions and 943 deletions
+3 -3
View File
@@ -160,7 +160,7 @@ export function togglePinKnowledgeBase(id: string) {
}
// 知识文件 API(基于具体知识库)
// data.tag_ids: 可选,指定知识所属的多个分类ID
// data.tag_ids: 可选,指定知识所属的多个标签 ID
export function uploadKnowledgeFile(
kbId: string,
data: {
@@ -188,7 +188,7 @@ export function uploadKnowledgeFile(
}
// 从URL创建知识
// data.tag_ids: 可选,指定知识所属的多个分类ID
// data.tag_ids: 可选,指定知识所属的多个标签 ID
export function createKnowledgeFromURL(
kbId: string,
data: { url: string; enable_multimodel?: boolean; tag_ids?: string[]; process_config?: KnowledgeProcessOverrides },
@@ -197,7 +197,7 @@ export function createKnowledgeFromURL(
}
// 手工创建知识
// data.tag_ids: 可选,指定知识所属的分类ID
// data.tag_ids: 可选,指定知识所属的标签 ID
export function createManualKnowledge(
kbId: string,
data: {
+78 -54
View File
@@ -1,19 +1,20 @@
<template>
<t-popup
v-if="kbInfo"
trigger="click"
placement="bottom-right"
:overlay-style="{ padding: 0 }"
:overlay-inner-style="{ padding: 0 }"
>
<template #content>
<t-tooltip :content="t('knowledgeBase.infoCard.tooltip')" placement="top">
<t-popup
v-if="kbInfo"
trigger="click"
placement="bottom-right"
:overlay-style="{ padding: 0 }"
:overlay-inner-style="{ padding: 0 }"
>
<template #content>
<div class="kb-info-card">
<div class="kb-info-card-header">{{ t('knowledgeBase.infoCard.title') }}</div>
<div class="kb-info-card-body">
<div class="kb-info-card-section">
<div class="kb-info-card-section-title">
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">
{{ t('knowledgeBase.infoCard.basic') }}
</div>
</h4>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.type') }}</span>
<span class="kb-info-card-value">
@@ -44,11 +45,11 @@
>.{{ ft }}</span>
</span>
</div>
</div>
<div class="kb-info-card-section">
<div class="kb-info-card-section-title">
</section>
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">
{{ t('knowledgeBase.infoCard.access') }}
</div>
</h4>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.accessInfo.myRole') }}</span>
<span class="kb-info-card-value">
@@ -75,11 +76,11 @@
{{ t('knowledgeList.sharedToOrgs', { count: kbInfo.share_count }) }}
</span>
</div>
</div>
<div v-if="capabilities.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
</section>
<section v-if="capabilities.length" class="setting-drawer__section">
<h4 class="setting-drawer__section-title">
{{ t('knowledgeBase.infoCard.capabilities') }}
</div>
</h4>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.enabled') }}</span>
<span class="kb-info-card-value">
@@ -94,11 +95,11 @@
</t-tag>
</span>
</div>
</div>
<div v-if="chunkingRows.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
</section>
<section v-if="chunkingRows.length" class="setting-drawer__section">
<h4 class="setting-drawer__section-title">
{{ t('knowledgeBase.infoCard.chunking') }}
</div>
</h4>
<div
v-for="row in chunkingRows"
:key="row.key"
@@ -107,11 +108,11 @@
<span class="kb-info-card-label">{{ row.label }}</span>
<span class="kb-info-card-value">{{ row.value }}</span>
</div>
</div>
<div v-if="statRows.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
</section>
<section v-if="statRows.length" class="setting-drawer__section">
<h4 class="setting-drawer__section-title">
{{ t('knowledgeBase.infoCard.stats') }}
</div>
</h4>
<div
v-for="stat in statRows"
:key="stat.key"
@@ -120,14 +121,14 @@
<span class="kb-info-card-label">{{ stat.label }}</span>
<span class="kb-info-card-value kb-info-card-value-number">{{ stat.value }}</span>
</div>
</div>
<div
</section>
<section
v-if="kbInfo.vector_store_source || kbInfo.storage_provider_config?.provider"
class="kb-info-card-section"
class="setting-drawer__section"
>
<div class="kb-info-card-section-title">
<h4 class="setting-drawer__section-title">
{{ t('knowledgeBase.infoCard.binding') }}
</div>
</h4>
<div v-if="kbInfo.vector_store_source" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.vectorStore') }}</span>
<span class="kb-info-card-value">
@@ -148,20 +149,19 @@
{{ kbInfo.storage_provider_config.provider }}
</span>
</div>
</div>
</section>
</div>
</div>
</template>
<t-tooltip :content="t('knowledgeBase.infoCard.tooltip')" placement="top">
<button
type="button"
class="kb-info-button"
:class="{ 'has-warning': kbInfo?.vector_store_status === 'unavailable' }"
>
<t-icon name="info-circle" size="16px" />
</button>
</t-tooltip>
<button
type="button"
class="kb-info-button"
:class="{ 'has-warning': kbInfo?.vector_store_status === 'unavailable' }"
>
<t-icon name="info-circle" size="16px" />
</button>
</t-popup>
</t-tooltip>
</template>
<script setup lang="ts">
@@ -455,28 +455,52 @@ const statRows = computed<Array<{ key: string; label: string; value: number | st
overflow-y: auto;
margin: 0 -16px;
padding: 0 16px;
display: flex;
flex-direction: column;
}
.kb-info-card-section + .kb-info-card-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--td-component-stroke);
.kb-info-card-body .setting-drawer__section {
padding: 12px 0 16px;
border-bottom: 1px solid var(--td-component-stroke);
display: flex;
flex-direction: column;
gap: 10px;
}
.kb-info-card-section-title {
font-size: 11px;
font-weight: 500;
color: var(--td-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
.kb-info-card-body .setting-drawer__section:first-child {
padding-top: 0;
}
.kb-info-card-body .setting-drawer__section:last-child {
border-bottom: none;
padding-bottom: 0;
}
.kb-info-card-body .setting-drawer__section-title {
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 4px;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
}
.kb-info-card-body .setting-drawer__section-title::before {
content: '';
width: 3px;
height: 14px;
background: var(--td-brand-color);
border-radius: 2px;
flex-shrink: 0;
}
.kb-info-card-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 0;
padding: 0;
line-height: 1.6;
}
+422 -338
View File
@@ -31,6 +31,28 @@ const canDeleteGeneratedQuestion = computed(() => {
return authStore.hasRole('admin');
});
const detailTags = computed(() => {
const tags = props.details?.tags;
return Array.isArray(tags) ? tags : [];
});
const headerIconName = computed(() => {
switch (props.details?.type) {
case 'url':
return 'link';
case 'manual':
return 'edit';
default:
return 'file';
}
});
const showSummarySection = computed(() =>
Boolean(props.details?.description)
|| props.details?.summary_status === 'pending'
|| props.details?.summary_status === 'processing',
);
// Mermaid 初始化计数器,用于生成唯一ID
let mermaidRenderCount = 0;
@@ -1065,11 +1087,13 @@ const handleDetailsScroll = () => {
:footer="false" :class="['doc-main-drawer', { 'doc-main-drawer--resizing': mainDrawerResizing }]"
@close="handleClose">
<template #header>
<div class="drawer-header">
<span class="header-title">{{ getDisplayTitle() }}</span>
<t-tag v-if="details.type" class="header-type-tag" size="small" :theme="getTypeTheme()" variant="light">
{{ getTypeLabel() }}
</t-tag>
<div class="doc-drawer-header">
<div class="doc-drawer-header-icon">
<t-icon :name="headerIconName" />
</div>
<div class="doc-drawer-header-text">
<div class="doc-drawer-header-title">{{ getDisplayTitle() }}</div>
</div>
<div class="header-actions">
<t-button v-if="details.type === 'file' || details.type === 'manual'" class="header-action-btn" size="small"
variant="text" shape="square" theme="default" :title="$t('common.download') || 'Download'"
@@ -1116,58 +1140,81 @@ const handleDetailsScroll = () => {
</div>
</t-drawer>
<div ref="docMarkdownRoot" class="doc-markdown-root">
<!-- URL类型专属区域(保留:source 是真实链接,不与标题重复) -->
<div v-if="details.type === 'url'" class="url_box">
<span class="label">{{ $t('knowledgeBase.urlSource') }}</span>
<div class="url_link_box">
<a :href="isValidURL(details.source) ? details.source : 'javascript:void(0)'"
:target="isValidURL(details.source) ? '_blank' : undefined" class="url_link">
<t-icon name="link" size="14px" />
<span class="url_text">{{ details.source }}</span>
<t-icon name="jump" size="14px" class="jump-icon" />
</a>
</div>
</div>
<div ref="docMarkdownRoot" class="doc-markdown-root doc-drawer-body setting-drawer__body">
<section v-if="details.id" class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('knowledgeBase.detailSectionMeta') }}</h4>
<div class="doc-detail-rows">
<div v-if="details.time" class="doc-detail-row">
<span class="doc-detail-label">{{ getTimeLabel() }}</span>
<span class="doc-detail-value">{{ details.time }}</span>
</div>
<div v-if="details.type" class="doc-detail-row">
<span class="doc-detail-label">{{ $t('knowledgeBase.infoCard.type') }}</span>
<span class="doc-detail-value">
<t-tag size="small" :theme="getTypeTheme()" variant="light">{{ getTypeLabel() }}</t-tag>
</span>
</div>
<div v-if="details.channel && details.channel !== 'web'" class="doc-detail-row">
<span class="doc-detail-label">{{ $t('knowledgeBase.infoCard.source') }}</span>
<span class="doc-detail-value">
<t-tag size="small" variant="light" theme="warning">{{ getChannelLabel(details.channel) }}</t-tag>
</span>
</div>
<div v-if="detailTags.length > 0" class="doc-detail-row">
<span class="doc-detail-label">{{ $t('knowledgeBase.tagLabel') }}</span>
<span class="doc-detail-value doc-tag-chips">
<t-tag
v-for="tag in detailTags"
:key="tag.id"
size="small"
variant="light-outline"
class="doc-tag-chip"
>
<span class="tag-text">{{ tag.name }}</span>
</t-tag>
</span>
</div>
</div>
</section>
<!-- 文档摘要 -->
<div v-if="details.description" class="summary_box">
<span class="label">{{ $t('knowledgeBase.documentSummary') }}</span>
<div class="summary_wrapper" :class="{ 'summary_clickable': summaryOverflow || summaryExpanded }"
@click="(summaryOverflow || summaryExpanded) && (summaryExpanded = !summaryExpanded)">
<div ref="summaryRef" :class="['summary_content', { 'summary_collapsed': !summaryExpanded }]">{{
details.description
<section v-if="details.type === 'url'" class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('knowledgeBase.urlSource') }}</h4>
<div class="url_link_box">
<a :href="isValidURL(details.source) ? details.source : 'javascript:void(0)'"
:target="isValidURL(details.source) ? '_blank' : undefined" class="url_link">
<t-icon name="link" size="14px" />
<span class="url_text">{{ details.source }}</span>
<t-icon name="jump" size="14px" class="jump-icon" />
</a>
</div>
</section>
<section v-if="showSummarySection" class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('knowledgeBase.documentSummary') }}</h4>
<div v-if="details.description" class="summary_wrapper"
:class="{ 'summary_clickable': summaryOverflow || summaryExpanded }"
@click="(summaryOverflow || summaryExpanded) && (summaryExpanded = !summaryExpanded)">
<div ref="summaryRef" :class="['summary_content', { 'summary_collapsed': !summaryExpanded }]">{{
details.description
}}</div>
<div v-if="(summaryOverflow && !summaryExpanded) || summaryExpanded" class="summary_fade"
:class="{ 'summary_fade_expanded': summaryExpanded }">
<t-icon :name="summaryExpanded ? 'chevron-up' : 'chevron-down'" size="14px" class="summary_fade_icon" />
<div v-if="(summaryOverflow && !summaryExpanded) || summaryExpanded" class="summary_fade"
:class="{ 'summary_fade_expanded': summaryExpanded }">
<t-icon :name="summaryExpanded ? 'chevron-up' : 'chevron-down'" size="14px" class="summary_fade_icon" />
</div>
</div>
</div>
</div>
<div v-else-if="details.summary_status === 'pending' || details.summary_status === 'processing'"
class="summary_box">
<span class="label">{{ $t('knowledgeBase.documentSummary') }}</span>
<div class="summary_loading">
<t-loading size="small" />
<span>{{ $t('knowledgeBase.generatingSummary') }}</span>
</div>
</div>
<div v-else class="summary_loading">
<t-loading size="small" />
<span>{{ $t('knowledgeBase.generatingSummary') }}</span>
</div>
</section>
<div class="content_header">
<div class="header-left">
<div class="title-row">
<span class="label">{{ getContentLabel() }}</span>
<span v-if="details.total > 0" class="chunk-count">
{{ $t('knowledgeBase.chunkCount', { count: details.total }) }}
</span>
</div>
<div class="meta-row">
<div class="meta-left">
<span class="time"> {{ getTimeLabel() }}{{ details.time }} </span>
<t-tag v-if="details.channel && details.channel !== 'web'" size="small" variant="light" theme="warning"
class="channel-tag">
{{ getChannelLabel(details.channel) }}
</t-tag>
<section class="setting-drawer__section doc-content-section">
<div class="doc-content-section-head">
<div class="doc-content-section-head-left">
<h4 class="setting-drawer__section-title">{{ getContentLabel() }}</h4>
<span v-if="details.total > 0" class="chunk-count">
{{ $t('knowledgeBase.chunkCount', { count: details.total }) }}
</span>
</div>
<div class="view-mode-buttons">
<t-button v-if="canPreview()" size="small" :variant="viewMode === 'preview' ? 'base' : 'outline'"
@@ -1187,87 +1234,86 @@ const handleDetailsScroll = () => {
</t-button>
</div>
</div>
</div>
</div>
<!-- 音频播放器(音频文件时固定显示在内容区顶部) -->
<div v-if="isAudioFile(details.file_type)" class="audio-player-section">
<div v-if="audioLoading" class="audio-loading">
<t-loading size="small" />
<span>{{ $t('preview.audioLoading') }}</span>
</div>
<audio v-else-if="audioBlobUrl" controls class="audio-player" :src="audioBlobUrl">
{{ $t('preview.audioNotSupported') }}
</audio>
</div>
<!-- 合并视图 -->
<div v-if="viewMode === 'merged'">
<div v-if="!mergedContent" class="no_content">{{ $t('common.noData') }}</div>
<div v-else class="md-content" v-html="processMarkdown(mergedContent)"></div>
</div>
<!-- 分块视图 -->
<div v-else-if="viewMode === 'chunks'">
<div v-if="!processedChunks.length" class="no_content">{{ $t('common.noData') }}</div>
<div v-else class="chunk-list">
<div class="chunk-item" v-for="(chunk, index) in processedChunks" :key="index">
<div class="chunk-header">
<span class="chunk-index">{{ $t('knowledgeBase.segment') }} {{ index + 1 }}</span>
<div class="chunk-header-right">
<t-tag v-if="chunk.hasParent" size="small" theme="primary" variant="light">
{{ $t('knowledgeBase.childChunk') }}
</t-tag>
<t-tag v-if="chunk.questions.length > 0" size="small" theme="success" variant="light">
{{ $t('knowledgeBase.questions') }} {{ chunk.questions.length }}
</t-tag>
<span class="chunk-meta">{{ chunk.meta }}</span>
</div>
<!-- 音频播放器(音频文件时固定显示在内容区顶部) -->
<div v-if="isAudioFile(details.file_type)" class="audio-player-section">
<div v-if="audioLoading" class="audio-loading">
<t-loading size="small" />
<span>{{ $t('preview.audioLoading') }}</span>
</div>
<div class="md-content" v-html="chunk.processedContent"></div>
<audio v-else-if="audioBlobUrl" controls class="audio-player" :src="audioBlobUrl">
{{ $t('preview.audioNotSupported') }}
</audio>
</div>
<!-- 父 Chunk 上下文展开 -->
<div v-if="chunk.hasParent" class="parent-context-section">
<div class="parent-context-toggle" @click="toggleParentContext(chunk.original, index)">
<t-icon v-if="!parentContextLoading.has(index)"
:name="isParentExpanded(index) ? 'chevron-down' : 'chevron-right'" size="14px" />
<t-loading v-else size="small" style="width: 14px; height: 14px;" />
<span>{{ $t('knowledgeBase.viewParentContext') }}</span>
</div>
<div v-show="isParentExpanded(index)" class="parent-context-content">
<div class="md-content" v-html="processMarkdown(getParentContent(chunk.original))"></div>
</div>
</div>
<!-- 合并视图 -->
<div v-if="viewMode === 'merged'">
<div v-if="!mergedContent" class="no_content">{{ $t('common.noData') }}</div>
<div v-else class="md-content" v-html="processMarkdown(mergedContent)"></div>
</div>
<!-- 生成的问题展示 -->
<div v-if="chunk.questions.length > 0" class="questions-section">
<div class="questions-toggle" @click="toggleQuestions(index)">
<t-icon :name="isExpanded(index) ? 'chevron-down' : 'chevron-right'" size="14px" />
<span>{{ $t('knowledgeBase.generatedQuestions') }} ({{ chunk.questions.length }})</span>
</div>
<div v-show="isExpanded(index)" class="questions-list">
<div v-for="question in chunk.questions" :key="question.id" class="question-item">
<t-icon name="help-circle" size="14px" class="question-icon" />
<span class="question-text">{{ question.question }}</span>
<t-button v-if="canDeleteGeneratedQuestion" theme="default" variant="text" size="small"
class="delete-question-btn" :loading="isDeleting(index, question.id)"
@click.stop="handleDeleteQuestion(chunk.original, index, question)">
<template #icon>
<t-icon name="delete" size="14px" />
</template>
</t-button>
<!-- 分块视图 -->
<div v-else-if="viewMode === 'chunks'">
<div v-if="!processedChunks.length" class="no_content">{{ $t('common.noData') }}</div>
<div v-else class="chunk-list">
<div class="chunk-item" v-for="(chunk, index) in processedChunks" :key="index">
<div class="chunk-header">
<span class="chunk-index">{{ $t('knowledgeBase.segment') }} {{ index + 1 }}</span>
<div class="chunk-header-right">
<t-tag v-if="chunk.hasParent" size="small" theme="primary" variant="light">
{{ $t('knowledgeBase.childChunk') }}
</t-tag>
<t-tag v-if="chunk.questions.length > 0" size="small" theme="success" variant="light">
{{ $t('knowledgeBase.questions') }} {{ chunk.questions.length }}
</t-tag>
<span class="chunk-meta">{{ chunk.meta }}</span>
</div>
</div>
<div class="md-content" v-html="chunk.processedContent"></div>
<!-- 父 Chunk 上下文展开 -->
<div v-if="chunk.hasParent" class="parent-context-section">
<div class="parent-context-toggle" @click="toggleParentContext(chunk.original, index)">
<t-icon v-if="!parentContextLoading.has(index)"
:name="isParentExpanded(index) ? 'chevron-down' : 'chevron-right'" size="14px" />
<t-loading v-else size="small" style="width: 14px; height: 14px;" />
<span>{{ $t('knowledgeBase.viewParentContext') }}</span>
</div>
<div v-show="isParentExpanded(index)" class="parent-context-content">
<div class="md-content" v-html="processMarkdown(getParentContent(chunk.original))"></div>
</div>
</div>
<!-- 生成的问题展示 -->
<div v-if="chunk.questions.length > 0" class="questions-section">
<div class="questions-toggle" @click="toggleQuestions(index)">
<t-icon :name="isExpanded(index) ? 'chevron-down' : 'chevron-right'" size="14px" />
<span>{{ $t('knowledgeBase.generatedQuestions') }} ({{ chunk.questions.length }})</span>
</div>
<div v-show="isExpanded(index)" class="questions-list">
<div v-for="question in chunk.questions" :key="question.id" class="question-item">
<t-icon name="help-circle" size="14px" class="question-icon" />
<span class="question-text">{{ question.question }}</span>
<t-button v-if="canDeleteGeneratedQuestion" theme="default" variant="text" size="small"
class="delete-question-btn" :loading="isDeleting(index, question.id)"
@click.stop="handleDeleteQuestion(chunk.original, index, question)">
<template #icon>
<t-icon name="delete" size="14px" />
</template>
</t-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 文档预览视图 -->
<div v-else-if="viewMode === 'preview'">
<DocumentPreview :knowledgeId="details.id" :fileType="details.file_type" :fileName="details.title"
:active="viewMode === 'preview'" />
</div>
<!-- 文档预览视图 -->
<div v-else-if="viewMode === 'preview'">
<DocumentPreview :knowledgeId="details.id" :fileType="details.file_type" :fileName="details.title"
:active="viewMode === 'preview'" />
</div>
</section>
</div>
</t-drawer>
@@ -1326,87 +1372,169 @@ const handleDetailsScroll = () => {
font-weight: normal;
}
:deep(.t-drawer__body.narrow-scrollbar) {
padding: 16px 20px;
.doc-drawer-header {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
width: 100%;
padding-right: 32px;
}
.drawer-header {
.doc-drawer-header-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(7, 192, 95, 0.1);
color: var(--td-brand-color);
font-size: 16px;
}
.doc-drawer-header-text {
flex: 1 1 auto;
min-width: 0;
}
.doc-drawer-header-title {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-drawer-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.doc-drawer-body .setting-drawer__section {
padding: 12px 0 16px;
border-bottom: 1px solid var(--td-component-stroke);
display: flex;
flex-direction: column;
gap: 14px;
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.doc-drawer-body .setting-drawer__section-title {
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 4px;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
width: 3px;
height: 14px;
background: var(--td-brand-color);
border-radius: 2px;
flex-shrink: 0;
}
}
.doc-detail-rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.doc-detail-row {
display: flex;
align-items: flex-start;
gap: 12px;
line-height: 1.6;
}
.doc-detail-label {
flex: 0 0 72px;
font-size: 12px;
color: var(--td-text-color-secondary);
}
.doc-detail-value {
flex: 1;
min-width: 0;
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--td-text-color-primary);
word-break: break-word;
}
.doc-content-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.doc-content-section-head-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
/* TDesign 抽屉的 X 关闭按钮浮在 header 右上角(约 16px 宽 + 16px 间距),
给右侧留出空间,避免我们的图标按钮被 X 遮挡。 */
padding-right: 32px;
flex: 1;
.header-title {
/* flex: 1 1 auto + min-width:0 让标题在标题超长时收缩出省略号,
而不是把右侧 tag/操作按钮挤出 header。 */
flex: 1 1 auto;
min-width: 0;
font-size: 16px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.setting-drawer__section-title {
margin-bottom: 0;
}
}
.doc-content-section {
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
flex-grow: 0;
}
.header-action-btn {
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
flex-shrink: 0;
color: var(--td-text-color-secondary);
border-radius: 4px;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--td-bg-color-container-hover);
color: var(--td-text-color-primary);
}
.header-type-tag {
flex-shrink: 0;
}
.header-actions {
:deep(.t-button__text) {
display: flex;
align-items: center;
gap: 2px;
/* 关键:操作区永不收缩,标题再长也能完整看到图标 */
flex-shrink: 0;
flex-grow: 0;
justify-content: center;
}
.header-action-btn {
/* 28×28 文本按钮:无边框,与抽屉头部融为一体;hover 时浅灰背景,
与右上角 X 关闭按钮的视觉风格一致。 */
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
flex-shrink: 0;
color: var(--td-text-color-secondary);
border-radius: 4px;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--td-bg-color-container-hover);
color: var(--td-text-color-primary);
}
:deep(.t-button__text) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
// 信息面板通用样式(仅 url_box 在用,file/manual 已合并到 header
.info_panel {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.url_box {
.info_panel();
}
.parse_timeline_box {
margin-top: 8px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--td-bg-color-component);
border-radius: 6px;
}
/* Hidden mount keeps fetcher live without showing UI */
@@ -1422,8 +1550,6 @@ const handleDetailsScroll = () => {
height: 100%;
width: 100%;
background: var(--td-bg-color-container);
/* Belt-and-suspenders: even if some inner element forgets a min-width:0
declaration in a flex chain, clip rather than overflow the drawer. */
overflow: hidden;
min-width: 0;
}
@@ -1433,10 +1559,6 @@ const handleDetailsScroll = () => {
height: 100%;
}
/* Width is set via the :size prop on the <t-drawer>, not CSS — see the
<script> for mainDrawerSize / timelineDrawerSize. Only padding +
background are still tweaked here so the timeline fills the secondary
drawer cleanly, edge-to-edge. */
:deep(.kp-secondary-drawer .t-drawer__body) {
padding: 0 !important;
}
@@ -1446,83 +1568,61 @@ const handleDetailsScroll = () => {
}
// 文档摘要区域
.summary_box {
display: flex;
flex-direction: column;
margin-bottom: 24px;
margin-top: 8px;
.summary_wrapper {
position: relative;
background: var(--td-bg-color-container-hover);
border-radius: 4px;
.label {
margin-bottom: 8px;
font-weight: 600;
font-size: 14px;
}
.summary_wrapper {
position: relative;
background: var(--td-bg-color-container-hover);
border-radius: 4px;
&.summary_clickable {
cursor: pointer;
}
}
.summary_content {
padding: 12px;
color: var(--td-text-color-primary);
font-size: 13px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
&.summary_collapsed {
max-height: 4.5em;
overflow: hidden;
}
}
.summary_fade {
display: flex;
justify-content: center;
padding-bottom: 4px;
pointer-events: none;
&:not(.summary_fade_expanded) {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 28px;
background: linear-gradient(transparent, var(--td-bg-color-container-hover) 80%);
border-radius: 0 0 4px 4px;
align-items: flex-end;
}
}
.summary_fade_icon {
color: var(--td-text-color-placeholder);
}
.summary_loading {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--td-bg-color-container-hover);
border-radius: 4px;
color: var(--td-text-color-placeholder);
font-size: 13px;
&.summary_clickable {
cursor: pointer;
}
}
.label {
.summary_content {
padding: 12px;
color: var(--td-text-color-primary);
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 22px;
margin-bottom: 8px;
font-size: 13px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
&.summary_collapsed {
max-height: 4.5em;
overflow: hidden;
}
}
.summary_fade {
display: flex;
justify-content: center;
padding-bottom: 4px;
pointer-events: none;
&:not(.summary_fade_expanded) {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 28px;
background: linear-gradient(transparent, var(--td-bg-color-container-hover) 80%);
border-radius: 0 0 4px 4px;
align-items: flex-end;
}
}
.summary_fade_icon {
color: var(--td-text-color-placeholder);
}
.summary_loading {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--td-bg-color-container-hover);
border-radius: 4px;
color: var(--td-text-color-placeholder);
font-size: 13px;
}
// URL链接区域
@@ -1551,79 +1651,52 @@ const handleDetailsScroll = () => {
}
}
.content_header {
margin-top: 16px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--td-component-stroke);
display: flex;
flex-direction: column;
gap: 12px;
.doc-tag-chips {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.header-left {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.doc-tag-chip {
max-width: 140px;
height: 20px;
line-height: 20px;
border-radius: 999px;
border-color: var(--td-component-stroke);
color: var(--td-text-color-secondary);
padding: 0 8px;
background: transparent;
.title-row {
display: flex;
align-items: center;
gap: 8px;
.label {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--td-text-color-primary);
}
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
flex-wrap: wrap;
gap: 12px;
}
.meta-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.channel-tag {
flex-shrink: 0;
}
.chunk-count {
color: var(--td-text-color-secondary);
font-size: 12px;
background: var(--td-bg-color-container-hover);
padding: 2px 8px;
border-radius: 4px;
}
.view-mode-buttons {
display: flex;
gap: 4px;
.view-mode-btn {
height: 28px;
min-width: 60px;
}
.tag-text {
display: inline-block;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
font-size: 11px;
}
}
.time {
.chunk-count {
color: var(--td-text-color-secondary);
font-size: 12px;
font-style: normal;
font-weight: 400;
background: var(--td-bg-color-container-hover);
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.view-mode-buttons {
display: flex;
gap: 4px;
flex-shrink: 0;
.view-mode-btn {
height: 28px;
min-width: 60px;
}
}
.no_content {
@@ -1815,6 +1888,17 @@ const handleDetailsScroll = () => {
content background need to be flushed for the timeline to fill
edge-to-edge. -->
<style lang="less">
.t-drawer.doc-main-drawer {
.t-drawer__header {
padding: 14px 18px;
border-bottom: 1px solid var(--td-component-stroke);
}
.t-drawer__body {
padding: 16px 18px;
}
}
/* 主抽屉宽度可调:拖拽手柄通过 teleport 挂到 body,不受 scoped 影响,
故样式写在非 scoped 块里。手柄贴在抽屉面板左缘(right = 抽屉宽度)。 */
.doc-drawer-resize-handle {
@@ -8,7 +8,7 @@
</div>
</teleport>
<t-drawer v-model:visible="drawerVisible" v-bind="drawerPassthroughAttrs" :size="effectiveWidth" :z-index="2500" placement="right"
attach="body" destroy-on-close
attach="body" destroy-on-close :footer="!hideFooter"
:class="drawerClass">
<!--
Custom header. We replace TDesign's default header so we can put a leading
+4 -1
View File
@@ -36,6 +36,7 @@ export default function (knowledgeBaseId?: string) {
error_message: "",
chunkLoading: false,
chunkLoadError: "",
tags: [] as Array<{ id: string; name: string; color?: string }>,
});
let knowledgeListGeneration = 0;
const getKnowled = (
@@ -153,7 +154,7 @@ export default function (knowledgeBaseId?: string) {
return;
}
// 获取当前选中的分类ID
// 获取当前选中的标签 ID
const uiStore = useUIStore();
const tagIdsToUpload = uiStore.selectedTagIds.length > 0 ? [...uiStore.selectedTagIds] : undefined;
@@ -189,6 +190,7 @@ export default function (knowledgeBaseId?: string) {
parse_status: "",
error_message: "",
chunkLoadError: "",
tags: item?.tags ? [...item.tags] : [],
});
getKnowledgeDetails(item.id)
.then((result: any) => {
@@ -206,6 +208,7 @@ export default function (knowledgeBaseId?: string) {
summary_status: data.summary_status || '',
parse_status: data.parse_status || '',
error_message: data.error_message || '',
tags: data.tags?.length ? data.tags : (item?.tags || []),
});
}
})
+28 -17
View File
@@ -332,7 +332,7 @@ export default {
roleOwner: 'Owner',
permissionOwner: 'Edit, manage settings, delete knowledge base',
permissionAdmin: 'Edit, manage sharing',
permissionEditor: 'Edit documents and categories',
permissionEditor: 'Edit documents and tags',
permissionViewer: 'View and search only',
fromOrg: 'From space',
sharedAt: 'Shared at',
@@ -368,7 +368,7 @@ export default {
files: 'Files',
settings: 'Settings',
publishToWeb: 'Publish to Web',
documentCategoryTitle: 'Document Categories',
documentCategoryTitle: 'Document Tags',
tagUpdateSuccess: 'Tag updated successfully',
tagEditDialogHeading: 'Edit tags',
tagEditDialogTitle: 'Edit Tags — {name}',
@@ -377,13 +377,22 @@ export default {
tagEditAvailableSection: 'Available',
tagEditNoSelected: 'None selected',
tagFilterAll: 'All documents',
tagFilterTitle: 'Filter by tag',
tagFilterPlaceholder: 'Tags',
tagFilterMulti: '{count} tags',
tagManageTitle: 'Manage tags',
tagManageDescription: 'Create, rename, or delete knowledge base tags',
tagManageLink: 'Manage tags…',
tagManageListSection: 'Tags',
tagManageDocCount: '{count} documents',
tagManageFaqCount: '{count} FAQ entries',
tagSelectedCount: '{count} selected',
tagOverflowTip: 'Click to edit tags',
tagNewPlaceholder: 'New tag name, press Enter to add',
category: 'Category',
faqCategoryTitle: 'FAQ Categories',
untagged: 'Uncategorized',
tagClearAction: 'Clear category',
category: 'Tag',
faqCategoryTitle: 'FAQ Tags',
untagged: 'Untagged',
tagClearAction: 'Clear selection',
tagSearchTooltip: 'Search tags',
tagCreateAction: 'Create tag',
tagSearchPlaceholder: 'Type to filter tags',
@@ -398,9 +407,9 @@ export default {
tagEditAction: 'Rename',
tagDeleteAction: 'Delete',
tagEmptyResult: 'No matching tags',
tagLabel: 'Category',
tagPlaceholder: 'Please select a category',
noTags: 'No categories',
tagLabel: 'Tag',
tagPlaceholder: 'Select tags',
noTags: 'No tags',
upload: 'Upload File',
uploadSuccess: 'File uploaded successfully!',
uploadFailed: 'File upload failed!',
@@ -562,6 +571,7 @@ export default {
parsingInProgress: 'Parsing...',
generatingSummary: 'Generating summary...',
documentSummary: 'Summary',
detailSectionMeta: 'Basic info',
deleteConfirmation: 'Delete Confirmation',
confirmDeleteDocument: 'Confirm deletion of document "{fileName}", recovery will be impossible after deletion',
cancel: 'Cancel',
@@ -635,6 +645,7 @@ export default {
docSearchPlaceholder: 'Search document names...',
fileTypeFilter: 'File Type',
allFileTypes: 'All Types',
allTags: 'All Tags',
parseStatusFilter: 'Status',
allParseStatuses: 'All Statuses',
parseStatusPending: 'Pending',
@@ -2717,8 +2728,8 @@ export default {
questionIndexModeLabel: 'Question Indexing Mode',
questionIndexModeDescription: 'Combined: Standard and similar questions are indexed together. Separate: Each question is indexed independently for more precise retrieval but requires more storage.',
entryGuide: 'Each FAQ entry contains a primary question, similar questions, negative examples, and multiple answers. Manage them in the FAQ knowledge base detail view.',
tagDesc: 'Select category for FAQ entries',
tagPlaceholder: 'Please select a category',
tagDesc: 'Select tags for FAQ entries',
tagPlaceholder: 'Select tags',
modes: {
questionOnly: 'Questions only',
questionAnswer: 'Question + answer',
@@ -2733,8 +2744,8 @@ export default {
similarQuestionsDesc: 'Add questions with the same meaning but different phrasing to help the system better match user queries.',
negativeQuestions: 'Negative Examples',
negativeQuestionsDesc: 'Add questions that should not match this answer, to exclude false positives.',
categoryLabel: 'FAQ Category',
categoryButton: 'Switch Category',
categoryLabel: 'FAQ Tag',
categoryButton: 'Switch Tag',
editorCreate: 'Create FAQ Entry',
editorEdit: 'Edit FAQ Entry',
addAnswer: 'Add Answer',
@@ -2783,8 +2794,8 @@ export default {
recommendedDisableSuccess: 'FAQ entry recommendation disabled',
recommendedUpdateFailed: 'Failed to update recommendation status',
batchOperations: 'Batch Operations',
batchUpdateTag: 'Batch Update Category',
batchUpdateTagTip: 'Set category for {count} selected entries',
batchUpdateTag: 'Batch Set Tags',
batchUpdateTagTip: 'Set tags for {count} selected entries',
batchEnable: 'Batch Enable',
batchDisable: 'Batch Disable',
batchEnableRecommended: 'Batch Enable Recommendation',
@@ -2796,7 +2807,7 @@ export default {
appendMode: 'Append',
replaceMode: 'Replace existing entries',
fileLabel: 'Select File',
fileTip: 'Supports JSON / CSV / Excel. CSV/Excel headers: Category (required), Question (required), Similar Questions (optional, separate with ##), Negative Questions (optional, separate with ##), Bot Answers (required, separate with ##), Reply All (optional, default FALSE), Disabled (optional, default FALSE), Exclude from Recommendations (optional, default FALSE). Also supports old format: standard_question, answers, similar_questions, negative_questions',
fileTip: 'Supports JSON / CSV / Excel. CSV/Excel headers: Tag (required), Question (required), Similar Questions (optional, separate with ##), Negative Questions (optional, separate with ##), Bot Answers (required, separate with ##), Reply All (optional, default FALSE), Disabled (optional, default FALSE), Exclude from Recommendations (optional, default FALSE). Legacy header "Category" and old format (standard_question, answers, similar_questions, negative_questions) are also supported.',
clickToUpload: 'Click to upload file',
dragDropTip: 'or drag and drop file here',
importButton: 'Import FAQ',
@@ -5663,7 +5674,7 @@ export default {
wikiSpace: 'Wiki Space',
resourceType: {
wikiSpace: 'Wiki Space',
docCategory: 'Document Category',
docCategory: 'Document Tag',
book: 'Yuque Book',
},
neverSynced: 'Never synced',
+28 -17
View File
@@ -332,7 +332,7 @@ export default {
roleOwner: "소유자",
permissionOwner: "설정을 편집, 관리하고 지식베이스를 삭제할 수 있습니다.",
permissionAdmin: "공유 설정 편집 및 관리",
permissionEditor: "편집 가능한 문서 및 카테고리",
permissionEditor: "편집 가능한 문서 및 태그",
permissionViewer: "보기 및 검색만 가능",
fromOrg: "공유 스페이스에서",
sharedAt: "공유일시",
@@ -367,10 +367,10 @@ export default {
description: "설명",
files: "파일",
settings: "설정",
documentCategoryTitle: "문서 분류",
faqCategoryTitle: "질문 분류",
untagged: "미분류",
tagClearAction: "분류 해제",
documentCategoryTitle: "문서 태그",
faqCategoryTitle: "FAQ 태그",
untagged: "태그 없음",
tagClearAction: "선택 해제",
tagUpdateSuccess: "태그 업데이트 성공",
tagEditDialogHeading: "태그 편집",
tagEditDialogTitle: '태그 편집 — {name}',
@@ -379,11 +379,20 @@ export default {
tagEditAvailableSection: '선택 가능',
tagEditNoSelected: '선택 없음',
tagFilterAll: '전체 문서',
tagFilterTitle: '태그로 필터',
tagFilterPlaceholder: '태그',
tagFilterMulti: '태그 {count}개',
tagManageTitle: '태그 관리',
tagManageDescription: '지식베이스 태그 생성, 이름 변경 또는 삭제',
tagManageLink: '태그 관리…',
tagManageListSection: '태그 목록',
tagManageDocCount: '문서 {count}개',
tagManageFaqCount: 'FAQ {count}개',
tagSelectedCount: '{count}개 선택됨',
tagOverflowTip: '클릭하여 태그 편집',
tagNewPlaceholder: '새 태그 이름 입력, Enter로 추가',
tagSearchTooltip: "태그 검색",
category: "분류",
category: "태그",
tagCreateAction: "태그 생성",
tagSearchPlaceholder: "태그 이름 키워드 입력",
tagNamePlaceholder: "태그 이름을 입력하세요",
@@ -397,9 +406,9 @@ export default {
tagEditAction: "이름 변경",
tagDeleteAction: "삭제",
tagEmptyResult: "일치하는 태그를 찾을 수 없습니다",
tagLabel: "분류",
tagPlaceholder: "분류를 선택하세요",
noTags: "분류 없음",
tagLabel: "태그",
tagPlaceholder: "태그를 선택하세요",
noTags: "태그 없음",
upload: "파일 업로드",
uploadSuccess: "파일 업로드 성공!",
uploadFailed: "파일 업로드 실패!",
@@ -565,6 +574,7 @@ export default {
parsingInProgress: "파싱 중...",
generatingSummary: "요약 생성 중...",
documentSummary: "요약",
detailSectionMeta: "기본 정보",
deleteConfirmation: "삭제 확인",
confirmDeleteDocument: '"{fileName}" 문서를 삭제하시겠습니까? 삭제 후 복구할 수 없습니다',
cancel: "취소",
@@ -641,6 +651,7 @@ export default {
docSearchPlaceholder: "문서 이름 검색...",
fileTypeFilter: "파일 유형",
allFileTypes: "모든 유형",
allTags: "모든 태그",
parseStatusFilter: "상태",
allParseStatuses: "모든 상태",
parseStatusPending: "대기 중",
@@ -3600,8 +3611,8 @@ export default {
"병합 인덱스: 표준 질문과 유사 질문을 병합 인덱싱; 개별 인덱스: 표준 질문과 각 유사 질문을 독립적으로 인덱싱하여 더 정확하게 검색하지만 더 많은 저장 스페이스가 필요합니다",
entryGuide:
"FAQ 항목은 표준 질문, 유사 질문, 반례 및 여러 답변으로 구성됩니다. 지식베이스 세부 정보에서 일괄 가져오기 및 편집할 수 있습니다.",
tagDesc: "FAQ 항목에 분류 선택",
tagPlaceholder: "분류를 선택하세요",
tagDesc: "FAQ 항목에 태그 선택",
tagPlaceholder: "태그를 선택하세요",
modes: {
questionOnly: "표준 질문/유사 질문만",
questionAnswer: "표준 질문 + 답변",
@@ -3620,8 +3631,8 @@ export default {
negativeQuestions: "반례",
negativeQuestionsDesc:
"이 답변과 매칭되지 않아야 하는 질문을 추가하여 잘못된 매칭을 제외합니다.",
categoryLabel: "FAQ 분류",
categoryButton: "분류 전환",
categoryLabel: "FAQ 태그",
categoryButton: "태그 전환",
editorCreate: "FAQ 항목 추가",
editorEdit: "FAQ 항목 편집",
addAnswer: "답변 추가",
@@ -3671,8 +3682,8 @@ export default {
recommendedDisableSuccess: "FAQ 항목 추천이 비활성화되었습니다",
recommendedUpdateFailed: "추천 상태 업데이트 실패",
batchOperations: "일괄 작업",
batchUpdateTag: "일괄 분류",
batchUpdateTagTip: "{count}개 선택된 항목에 분류가 설정됩니다",
batchUpdateTag: "일괄 태그 설정",
batchUpdateTagTip: "{count}개 선택된 항목에 태그가 설정됩니다",
batchEnable: "일괄 활성화",
batchDisable: "일괄 비활성화",
batchEnableRecommended: "일괄 추천 활성화",
@@ -3685,7 +3696,7 @@ export default {
replaceMode: "기존 항목 교체",
fileLabel: "파일 선택",
fileTip:
"JSON / CSV / Excel 지원. CSV/Excel 헤더: 분류(필수), 질문(필수), 유사 질문(선택-##로 구분), 반례 질문(선택-##로 구분), 로봇 답변(필수-##로 구분), 모든 답변 여부(선택-기본값 FALSE), 비활성화 여부(선택-기본값 FALSE), 추천 금지 여부(선택-기본값 False 추천 가능). 이전 형식도 지원: standard_question, answers, similar_questions, negative_questions",
"JSON / CSV / Excel 지원. CSV/Excel 헤더: 태그(필수), 질문(필수), 유사 질문(선택-##로 구분), 반례 질문(선택-##로 구분), 로봇 답변(필수-##로 구분), 모든 답변 여부(선택-기본값 FALSE), 비활성화 여부(선택-기본값 FALSE), 추천 금지 여부(선택-기본값 False 추천 가능). 이전 헤더 '분류' 및 이전 형식(standard_question, answers, similar_questions, negative_questions)도 지원합니다.",
clickToUpload: "파일 업로드 클릭",
dragDropTip: "또는 파일을 여기에 드래그",
importButton: "FAQ 가져오기",
@@ -5676,7 +5687,7 @@ export default {
wikiSpace: "위키 공간",
resourceType: {
wikiSpace: "위키 공간",
docCategory: "문서 분류",
docCategory: "문서 태그",
book: "Yuque 지식베이스",
},
neverSynced: "동기화되지 않음",
+28 -17
View File
@@ -307,10 +307,10 @@ export default {
description: 'Описание',
files: 'Файлы',
settings: 'Настройки',
documentCategoryTitle: 'Категории документов',
faqCategoryTitle: 'Категории FAQ',
untagged: 'Без метки',
tagClearAction: 'Снять категорию',
documentCategoryTitle: 'Теги документов',
faqCategoryTitle: 'Теги FAQ',
untagged: 'Без тега',
tagClearAction: 'Очистить выбор',
tagSearchTooltip: 'Поиск тегов',
tagUpdateSuccess: 'Тег успешно обновлен',
tagEditDialogHeading: 'Редактировать теги',
@@ -320,10 +320,19 @@ export default {
tagEditAvailableSection: 'Доступные',
tagEditNoSelected: 'Ничего не выбрано',
tagFilterAll: 'Все документы',
tagFilterTitle: 'Фильтр по тегу',
tagFilterPlaceholder: 'Теги',
tagFilterMulti: '{count} тегов',
tagManageTitle: 'Управление тегами',
tagManageDescription: 'Создание, переименование и удаление тегов базы знаний',
tagManageLink: 'Управление тегами…',
tagManageListSection: 'Список тегов',
tagManageDocCount: '{count} документов',
tagManageFaqCount: '{count} записей FAQ',
tagSelectedCount: 'Выбрано: {count}',
tagOverflowTip: 'Нажмите, чтобы изменить теги',
tagNewPlaceholder: 'Название нового тега, Enter для добавления',
category: 'Категория',
category: 'Тег',
tagCreateAction: 'Создать тег',
tagSearchPlaceholder: 'Введите название тега',
tagNamePlaceholder: 'Введите название тега',
@@ -337,9 +346,9 @@ export default {
tagEditAction: 'Переименовать',
tagDeleteAction: 'Удалить',
tagEmptyResult: 'Подходящие теги не найдены',
tagLabel: 'Категория',
tagPlaceholder: 'Пожалуйста, выберите категорию',
noTags: 'Нет категорий',
tagLabel: 'Тег',
tagPlaceholder: 'Выберите теги',
noTags: 'Нет тегов',
upload: 'Загрузить файл',
uploadSuccess: 'Файл успешно загружен!',
uploadFailed: 'Ошибка загрузки файла!',
@@ -487,6 +496,7 @@ export default {
parsingInProgress: 'Парсинг...',
generatingSummary: 'Генерация резюме...',
documentSummary: 'Резюме',
detailSectionMeta: 'Основная информация',
deleteConfirmation: 'Подтверждение удаления',
confirmDeleteDocument: 'Подтвердить удаление документа "{fileName}", после удаления восстановление невозможно',
cancel: 'Отмена',
@@ -559,6 +569,7 @@ export default {
docSearchPlaceholder: 'Поиск документов...',
fileTypeFilter: 'Тип файла',
allFileTypes: 'Все типы',
allTags: 'Все теги',
parseStatusFilter: 'Статус',
allParseStatuses: 'Все статусы',
parseStatusPending: 'Ожидание',
@@ -592,7 +603,7 @@ export default {
roleOwner: 'Создатель',
permissionOwner: 'Редактирование, управление настройками, удаление базы знаний',
permissionAdmin: 'Редактирование, управление общим доступом',
permissionEditor: 'Редактирование документов и категорий',
permissionEditor: 'Редактирование документов и тегов',
permissionViewer: 'Только просмотр',
fromOrg: 'Из пространства',
sharedAt: 'Дата общего доступа',
@@ -3087,8 +3098,8 @@ export default {
questionIndexModeLabel: 'Режим индексации вопросов',
questionIndexModeDescription: 'Объединенная: стандартные и похожие вопросы индексируются вместе. Раздельная: каждый вопрос индексируется независимо для более точного поиска, но требует больше места.',
entryGuide: 'Каждый FAQ включает основной вопрос, похожие вопросы, негативные примеры и несколько ответов. Управляйте ими в деталях FAQ-базы.',
tagDesc: 'Выберите категорию для записей FAQ',
tagPlaceholder: 'Пожалуйста, выберите категорию',
tagDesc: 'Выберите теги для записей FAQ',
tagPlaceholder: 'Выберите теги',
modes: {
questionOnly: 'Только вопросы',
questionAnswer: 'Вопрос + ответ',
@@ -3099,8 +3110,8 @@ export default {
answers: 'Ответы',
similarQuestions: 'Похожие вопросы',
negativeQuestions: 'Негативные примеры',
categoryLabel: 'Категория FAQ',
categoryButton: 'Сменить категорию',
categoryLabel: 'Тег FAQ',
categoryButton: 'Сменить тег',
editorCreate: 'Создать FAQ запись',
editorEdit: 'Редактировать FAQ запись',
addAnswer: 'Добавить ответ',
@@ -3146,8 +3157,8 @@ export default {
recommendedDisableSuccess: 'Рекомендация записи FAQ отключена',
recommendedUpdateFailed: 'Не удалось обновить статус рекомендации',
batchOperations: 'Пакетные операции',
batchUpdateTag: 'Пакетное обновление категории',
batchUpdateTagTip: 'Установить категорию для {count} выбранных записей',
batchUpdateTag: 'Пакетная установка тегов',
batchUpdateTagTip: 'Установить теги для {count} выбранных записей',
batchEnable: 'Пакетное включение',
batchDisable: 'Пакетное отключение',
batchEnableRecommended: 'Пакетное включение рекомендации',
@@ -3166,7 +3177,7 @@ export default {
appendMode: 'Добавить',
replaceMode: 'Заменить существующие записи',
fileLabel: 'Выберите файл',
fileTip: 'Поддерживаются JSON / CSV / Excel. Заголовки CSV/Excel: 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔), 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE), 是否禁止被推荐(选填-默认False 可被推荐). Также поддерживается старый формат: standard_question, answers, similar_questions, negative_questions',
fileTip: 'Поддерживаются JSON / CSV / Excel. Заголовки CSV/Excel: 标签(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔), 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE), 是否禁止被推荐(选填-默认False 可被推荐). Также поддерживаются старый заголовок «分类» и формат: standard_question, answers, similar_questions, negative_questions',
clickToUpload: 'Нажмите для загрузки файла',
dragDropTip: 'или перетащите файл сюда',
importButton: 'Импортировать FAQ',
@@ -5497,7 +5508,7 @@ export default {
wikiSpace: 'Пространство вики',
resourceType: {
wikiSpace: 'Пространство вики',
docCategory: 'Категория документов',
docCategory: 'Тег документа',
book: 'База знаний Yuque',
},
neverSynced: 'Не синхронизировано',
+28 -17
View File
@@ -332,7 +332,7 @@ export default {
roleOwner: "创建者",
permissionOwner: "可编辑、管理设置、删除知识库",
permissionAdmin: "可编辑、管理共享设置",
permissionEditor: "可编辑文档与分类",
permissionEditor: "可编辑文档与标签",
permissionViewer: "仅查看与检索",
fromOrg: "来自空间",
sharedAt: "共享于",
@@ -368,10 +368,10 @@ export default {
files: "文件",
settings: "设置",
publishToWeb: "发布到网站",
documentCategoryTitle: "文档分类",
faqCategoryTitle: "问题分类",
untagged: "未分类",
tagClearAction: "取消分类",
documentCategoryTitle: "文档标签",
faqCategoryTitle: "FAQ 标签",
untagged: "无标签",
tagClearAction: "清空已选",
tagUpdateSuccess: "标签更新成功",
tagEditDialogHeading: "编辑标签",
tagEditDialogTitle: '编辑标签 — {name}',
@@ -380,11 +380,20 @@ export default {
tagEditAvailableSection: "可选标签",
tagEditNoSelected: "暂未选择",
tagFilterAll: "全部文档",
tagFilterTitle: "按标签筛选",
tagFilterPlaceholder: "标签",
tagFilterMulti: "{count} 个标签",
tagManageTitle: "管理标签",
tagManageDescription: "新建、重命名或删除知识库标签",
tagManageLink: "管理标签…",
tagManageListSection: "标签列表",
tagManageDocCount: "{count} 个文档",
tagManageFaqCount: "{count} 个 FAQ",
tagSelectedCount: "已选 {count} 个标签",
tagOverflowTip: "点击编辑标签",
tagNewPlaceholder: "输入新标签名称,回车添加",
tagSearchTooltip: "搜索标签",
category: "分类",
category: "标签",
tagCreateAction: "新建标签",
tagSearchPlaceholder: "输入标签名称关键字",
tagNamePlaceholder: "请输入标签名称",
@@ -398,9 +407,9 @@ export default {
tagEditAction: "重命名",
tagDeleteAction: "删除",
tagEmptyResult: "未找到匹配的标签",
tagLabel: "分类",
tagPlaceholder: "请选择分类",
noTags: "暂无分类",
tagLabel: "标签",
tagPlaceholder: "请选择标签",
noTags: "暂无标签",
upload: "上传文件",
uploadSuccess: "文件上传成功!",
uploadFailed: "文件上传失败!",
@@ -563,6 +572,7 @@ export default {
parsingInProgress: "解析中...",
generatingSummary: "生成摘要中...",
documentSummary: "摘要",
detailSectionMeta: "基本信息",
deleteConfirmation: "删除确认",
confirmDeleteDocument: '确认删除文档"{fileName}",删除后将无法恢复',
cancel: "取消",
@@ -639,6 +649,7 @@ export default {
docSearchPlaceholder: "搜索文档名称...",
fileTypeFilter: "文件类型",
allFileTypes: "全部类型",
allTags: "全部标签",
parseStatusFilter: "解析状态",
allParseStatuses: "全部状态",
parseStatusPending: "等待中",
@@ -3619,8 +3630,8 @@ export default {
questionIndexModeLabel: "问题索引方式",
questionIndexModeDescription: "合并索引:标准问和相似问合并索引;分别索引:标准问和每个相似问独立索引,检索更精确但需要更多存储",
entryGuide: "FAQ 条目由标准问、相似问、反例和多个答案组成,可在知识库详情中批量导入、编辑。",
tagDesc: "为 FAQ 条目选择分类",
tagPlaceholder: "请选择分类",
tagDesc: "为 FAQ 条目选择标签",
tagPlaceholder: "请选择标签",
modes: {
questionOnly: "仅标准问/相似问",
questionAnswer: "标准问 + 答案",
@@ -3635,8 +3646,8 @@ export default {
similarQuestionsDesc: "添加与标准问意思相同但表述不同的问题,帮助系统更好地匹配用户查询。",
negativeQuestions: "反例",
negativeQuestionsDesc: "添加不应匹配此答案的问题,用于排除误匹配的情况。",
categoryLabel: "FAQ 分类",
categoryButton: "切换分类",
categoryLabel: "FAQ 标签",
categoryButton: "切换标签",
editorCreate: "新增 FAQ 条目",
editorEdit: "编辑 FAQ 条目",
addAnswer: "添加答案",
@@ -3685,8 +3696,8 @@ export default {
recommendedDisableSuccess: "FAQ 条目已关闭推荐",
recommendedUpdateFailed: "更新推荐状态失败",
batchOperations: "批量操作",
batchUpdateTag: "批量分类",
batchUpdateTagTip: "将为 {count} 个选中的条目设置分类",
batchUpdateTag: "批量设置标签",
batchUpdateTagTip: "将为 {count} 个选中的条目设置标签",
batchEnable: "批量启用",
batchDisable: "批量禁用",
batchEnableRecommended: "批量开启推荐",
@@ -3698,7 +3709,7 @@ export default {
appendMode: "追加导入",
replaceMode: "替换现有条目",
fileLabel: "选择文件",
fileTip: "支持 JSON / CSV / Excel。CSV/Excel 表头:分类(必填)、问题(必填)、相似问题(选填-多个用##分隔)、反例问题(选填-多个用##分隔)、机器人回答(必填-多个用##分隔)、是否全部回复(选填-默认FALSE)、是否停用(选填-默认FALSE)、是否禁止被推荐(选填-默认False 可被推荐)。也支持旧格式:standard_question、answers、similar_questions、negative_questions",
fileTip: "支持 JSON / CSV / Excel。CSV/Excel 表头:标签(必填)、问题(必填)、相似问题(选填-多个用##分隔)、反例问题(选填-多个用##分隔)、机器人回答(必填-多个用##分隔)、是否全部回复(选填-默认FALSE)、是否停用(选填-默认FALSE)、是否禁止被推荐(选填-默认False 可被推荐)。也支持旧表头「分类」及旧格式:standard_question、answers、similar_questions、negative_questions",
clickToUpload: "点击上传文件",
dragDropTip: "或拖拽文件到此处",
importButton: "导入 FAQ",
@@ -5681,7 +5692,7 @@ export default {
wikiSpace: "知识库空间",
resourceType: {
wikiSpace: "知识库空间",
docCategory: "文档分类",
docCategory: "文档标签",
book: "语雀知识库",
},
neverSynced: "未同步",
+2 -2
View File
@@ -7,7 +7,7 @@ export const useUIStore = defineStore('ui', {
kbEditorMode: 'create' as 'create' | 'edit',
currentKBId: null as string | null,
kbEditorType: 'document' as 'document' | 'faq',
// 当前选中的分类ID,用于文件上传时传递
// 当前选中的标签 ID,用于文件上传时传递
selectedTagIds: [] as string[],
kbEditorInitialSection: null as string | null,
settingsInitialSection: null as string | null,
@@ -106,7 +106,7 @@ export const useUIStore = defineStore('ui', {
this.manualEditorOnSuccess = null
},
// 设置当前选中的分类ID
// 设置当前选中的标签 ID
toggleSelectedTagId(tagId: string) {
const idx = this.selectedTagIds.indexOf(tagId)
if (idx >= 0) {
File diff suppressed because it is too large Load Diff
@@ -615,7 +615,7 @@
<div class="setting-row vertical">
<div class="setting-info">
<label>{{ $t('knowledgeBase.category') }}</label>
<label>{{ $t('knowledgeBase.tagLabel') }}</label>
<p class="desc">{{ $t('knowledgeEditor.faq.tagDesc') }}</p>
</div>
<div class="setting-control">
@@ -2039,7 +2039,7 @@ const parseCSVFile = async (file: File): Promise<FAQEntryPayload[]> => {
similar_questions: splitByDelimiter(record['相似问题'] || record['similar_questions']),
negative_questions: splitByDelimiter(record['反例问题'] || record['negative_questions']),
tag_id: record['tag_id'] ? Number(record['tag_id']) : undefined,
tag_name: record['分类'] || record['tag_name'] || '',
tag_name: record['标签'] || record['分类'] || record['tag_name'] || '',
is_enabled: isDisabled !== undefined ? !isDisabled : undefined, // 是否停用:FALSE表示启用,TRUE表示停用,所以取反
}),
)
@@ -2086,7 +2086,7 @@ const parseExcelFile = async (file: File): Promise<FAQEntryPayload[]> => {
similar_questions: splitByDelimiter(normalizedRow['相似问题'] || normalizedRow['similar_questions']),
negative_questions: splitByDelimiter(normalizedRow['反例问题'] || normalizedRow['negative_questions']),
tag_id: normalizedRow['tag_id'] ? Number(normalizedRow['tag_id']) : undefined,
tag_name: normalizedRow['分类'] || normalizedRow['tag_name'] || '',
tag_name: normalizedRow['标签'] || normalizedRow['分类'] || normalizedRow['tag_name'] || '',
is_enabled: isDisabled !== undefined ? !isDisabled : undefined, // 是否停用:FALSE表示启用,TRUE表示停用,所以取反
})
})
@@ -2563,10 +2563,10 @@ const downloadJSONExample = () => {
// 下载 CSV 示例
const downloadCSVExample = () => {
const headers = ['分类(必填)', '问题(必填)', '相似问题(选填-多个用##分隔)', '反例问题(选填-多个用##分隔)', '机器人回答(必填-多个用##分隔)', '是否全部回复(选填-默认FALSE)', '是否停用(选填-默认FALSE)', '是否禁止被推荐(选填-默认False 可被推荐)']
const headers = ['标签(必填)', '问题(必填)', '相似问题(选填-多个用##分隔)', '反例问题(选填-多个用##分隔)', '机器人回答(必填-多个用##分隔)', '是否全部回复(选填-默认FALSE)', '是否停用(选填-默认FALSE)', '是否禁止被推荐(选填-默认False 可被推荐)']
const rows = exampleData.map((item) => {
return [
item.tag_name || '', // 分类
item.tag_name || '', // 标签
item.standard_question,
item.similar_questions.join('##'),
item.negative_questions.join('##'),
@@ -2601,7 +2601,7 @@ const downloadCSVExample = () => {
const downloadExcelExample = () => {
const worksheet = XLSX.utils.json_to_sheet(
exampleData.map((item) => ({
'分类(必填)': item.tag_name || '',
'标签(必填)': item.tag_name || '',
'问题(必填)': item.standard_question,
'相似问题(选填-多个用##分隔)': item.similar_questions.join('##'),
'反例问题(选填-多个用##分隔)': item.negative_questions.join('##'),
@@ -5732,7 +5732,7 @@ watch(() => entries.value.map(e => ({
gap: 8px;
}
// 批量分类弹窗样式 - 与导入对话框风格一致
// 批量标签弹窗样式 - 与导入对话框风格一致
.batch-tag-overlay {
position: fixed;
inset: 0;
@@ -0,0 +1,705 @@
<template>
<SettingDrawer
v-model:visible="drawerVisible"
:title="$t('knowledgeBase.tagManageTitle')"
:description="$t('knowledgeBase.tagManageDescription')"
icon="discount"
width="480px"
:min-width="420"
:max-width="640"
resizable
storage-key="setting-drawer:width:kb-tag-manage"
:hide-footer="true"
>
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('knowledgeBase.tagManageListSection') }}</h4>
<div class="tag-manage-toolbar">
<div class="tag-manage-search-wrap">
<t-input
v-model.trim="searchQuery"
size="small"
:placeholder="$t('knowledgeBase.tagSearchPlaceholder')"
clearable
class="tag-manage-search"
>
<template #prefix-icon>
<t-icon name="search" size="14px" />
</template>
</t-input>
</div>
<t-tooltip :content="$t('knowledgeBase.tagCreateAction')" placement="top">
<t-button
size="small"
variant="text"
class="tag-manage-create-btn"
:disabled="creatingTag"
:aria-label="$t('knowledgeBase.tagCreateAction')"
@click="startCreateTag"
>
<template #icon><t-icon name="add" size="16px" /></template>
</t-button>
</t-tooltip>
</div>
<t-loading :loading="loading && !tags.length" size="small" class="tag-manage-loading">
<div v-if="!loading && !tags.length && !creatingTag" class="tag-manage-empty">
<t-empty :description="$t('knowledgeBase.tagEmptyResult')" />
</div>
<ul v-else class="tag-tile-grid">
<template v-if="loading && !tags.length">
<li v-for="n in 4" :key="'tag-skel-' + n" class="tag-tile tag-tile--skeleton">
<t-skeleton animation="gradient" :row-col="[{ width: '100%', height: '44px', type: 'rect' }]" />
</li>
</template>
<template v-else>
<li v-if="creatingTag" class="tag-tile tag-tile--editing" @click.stop>
<div class="tag-tile__main tag-tile__main--editing">
<span class="tag-tile__badge" aria-hidden="true">
<t-icon name="discount" size="15px" />
</span>
<t-input
ref="newTagInputRef"
v-model="newTagName"
size="small"
:maxlength="40"
class="tag-tile__input"
:placeholder="$t('knowledgeBase.tagNamePlaceholder')"
@enter="submitCreateTag"
@keydown="(_v, ctx) => onEditKeydown(ctx, cancelCreateTag)"
/>
</div>
<div class="tag-tile__actions">
<t-button
variant="text"
shape="square"
size="small"
class="tag-tile__action-btn tag-tile__action-btn--confirm"
:loading="creatingTagLoading"
:title="$t('common.create')"
@click.stop="submitCreateTag"
>
<template #icon><t-icon name="check" size="14px" /></template>
</t-button>
<t-button
variant="text"
shape="square"
size="small"
class="tag-tile__action-btn"
:title="$t('common.cancel')"
@click.stop="cancelCreateTag"
>
<template #icon><t-icon name="close" size="14px" /></template>
</t-button>
</div>
</li>
<li
v-for="tag in tags"
:key="tag.id"
class="tag-tile"
:class="{ 'tag-tile--editing': editingTagId === tag.id }"
@click.stop
>
<template v-if="editingTagId === tag.id">
<div class="tag-tile__main tag-tile__main--editing">
<span class="tag-tile__badge" aria-hidden="true">
<t-icon name="discount" size="15px" />
</span>
<t-input
:ref="(el: any) => setEditingTagInputRef(el, tag.id)"
v-model="editingTagName"
size="small"
:maxlength="40"
class="tag-tile__input"
:placeholder="$t('knowledgeBase.tagNamePlaceholder')"
@enter="submitEditTag"
@keydown="(_v, ctx) => onEditKeydown(ctx, cancelEditTag)"
/>
</div>
<div class="tag-tile__actions">
<t-button
variant="text"
shape="square"
size="small"
class="tag-tile__action-btn tag-tile__action-btn--confirm"
:loading="editingTagSubmitting"
:title="$t('common.save')"
@click.stop="submitEditTag"
>
<template #icon><t-icon name="check" size="14px" /></template>
</t-button>
<t-button
variant="text"
shape="square"
size="small"
class="tag-tile__action-btn"
:title="$t('common.cancel')"
@click.stop="cancelEditTag"
>
<template #icon><t-icon name="close" size="14px" /></template>
</t-button>
</div>
</template>
<template v-else>
<div class="tag-tile__main">
<span class="tag-tile__badge" aria-hidden="true">
<t-icon name="discount" size="15px" />
</span>
<span class="tag-tile__text">
<span class="tag-tile__name" :title="tag.name">{{ tag.name }}</span>
<span class="tag-tile__count">
{{
isFaq
? $t('knowledgeBase.tagManageFaqCount', { count: tag.knowledge_count || 0 })
: $t('knowledgeBase.tagManageDocCount', { count: tag.knowledge_count || 0 })
}}
</span>
</span>
</div>
<div class="tag-tile__actions" @click.stop>
<t-button
variant="text"
shape="square"
size="small"
class="tag-tile__action-btn"
:title="$t('knowledgeBase.tagEditAction')"
@click="startEditTag(tag)"
>
<template #icon><t-icon name="edit" size="14px" /></template>
</t-button>
<t-popconfirm
:content="getDeleteConfirmContent(tag)"
:confirm-btn="{ content: $t('common.delete'), theme: 'danger' }"
:cancel-btn="{ content: $t('common.cancel') }"
placement="bottom-right"
@confirm="deleteTag(tag)"
>
<t-button
theme="danger"
shape="square"
variant="text"
size="small"
class="tag-tile__action-btn"
:title="$t('knowledgeBase.tagDeleteAction')"
@click.stop
>
<template #icon><t-icon name="delete" size="14px" /></template>
</t-button>
</t-popconfirm>
</div>
</template>
</li>
</template>
</ul>
<div v-if="hasMore && tags.length" class="tag-load-more">
<t-button variant="text" size="small" :loading="loadingMore" @click="loadTags(false)">
{{ $t('tenant.loadMore') }}
</t-button>
</div>
</t-loading>
</section>
</SettingDrawer>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, computed, type ComponentPublicInstance } from 'vue';
import { useI18n } from 'vue-i18n';
import { MessagePlugin } from 'tdesign-vue-next';
import SettingDrawer from '@/components/settings/SettingDrawer.vue';
import {
listKnowledgeTags,
createKnowledgeBaseTag,
updateKnowledgeBaseTag,
deleteKnowledgeBaseTag,
} from '@/api/knowledge-base/index';
type TagRow = {
id: string;
seq_id: number;
name: string;
knowledge_count?: number;
};
type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>;
const TAG_PAGE_SIZE = 50;
const props = defineProps<{
visible: boolean;
kbId: string;
isFaq?: boolean;
}>();
const emit = defineEmits<{
'update:visible': [boolean];
changed: [payload?: { deletedTagId?: string }];
}>();
const { t } = useI18n();
const drawerVisible = computed({
get: () => props.visible,
set: (value: boolean) => emit('update:visible', value),
});
const tags = ref<TagRow[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const page = ref(1);
const hasMore = ref(false);
const total = ref(0);
const searchQuery = ref('');
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
const creatingTag = ref(false);
const creatingTagLoading = ref(false);
const newTagName = ref('');
const newTagInputRef = ref<TagInputInstance | null>(null);
const editingTagId = ref<string | null>(null);
const editingTagName = ref('');
const editingTagSubmitting = ref(false);
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 getDeleteConfirmContent = (tag: { name: string }) =>
t(props.isFaq ? 'knowledgeBase.tagDeleteDesc' : 'knowledgeBase.tagDeleteDescDoc', { name: tag.name });
const onEditKeydown = (ctx: { e?: KeyboardEvent }, cancel: () => void) => {
if (ctx?.e?.key === 'Escape') {
ctx.e.stopPropagation();
ctx.e.preventDefault();
cancel();
}
};
const resetLocalState = () => {
cancelCreateTag();
cancelEditTag();
searchQuery.value = '';
};
const loadTags = async (reset = false) => {
if (!props.kbId) {
tags.value = [];
total.value = 0;
hasMore.value = false;
page.value = 1;
return;
}
if (reset) {
page.value = 1;
tags.value = [];
total.value = 0;
hasMore.value = false;
} else if (loading.value || loadingMore.value) {
return;
}
const currentPage = page.value || 1;
loading.value = currentPage === 1;
loadingMore.value = currentPage > 1;
try {
const res: any = await listKnowledgeTags(props.kbId, {
page: currentPage,
page_size: TAG_PAGE_SIZE,
keyword: searchQuery.value || undefined,
});
const pageData = (res?.data || {}) as { data?: TagRow[]; total?: number };
const pageTags = (pageData.data || []).map((tag) => ({
...tag,
id: String(tag.id),
}));
if (currentPage === 1) {
tags.value = pageTags;
} else {
tags.value = [...tags.value, ...pageTags];
}
total.value = pageData.total || tags.value.length;
hasMore.value = tags.value.length < total.value;
if (hasMore.value) {
page.value = currentPage + 1;
}
} catch (error) {
console.error('Failed to load tags', error);
} finally {
loading.value = false;
loadingMore.value = false;
}
};
const startCreateTag = () => {
if (!props.kbId || 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) 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(true);
emit('changed');
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'));
} finally {
creatingTagLoading.value = false;
}
};
const startEditTag = (tag: TagRow) => {
cancelCreateTag();
editingTagId.value = tag.id;
editingTagName.value = tag.name;
nextTick(() => {
editingTagInputRefs.get(tag.id)?.focus?.();
editingTagInputRefs.get(tag.id)?.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;
}
const current = tags.value.find((tag) => tag.id === editingTagId.value);
if (current && name === current.name) {
cancelEditTag();
return;
}
editingTagSubmitting.value = true;
try {
await updateKnowledgeBaseTag(props.kbId, editingTagId.value, { name });
MessagePlugin.success(t('knowledgeBase.tagEditSuccess'));
cancelEditTag();
await loadTags(true);
emit('changed');
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'));
} finally {
editingTagSubmitting.value = false;
}
};
const deleteTag = async (tag: TagRow) => {
if (!props.kbId) return;
cancelCreateTag();
cancelEditTag();
try {
await deleteKnowledgeBaseTag(props.kbId, tag.seq_id, { force: true });
MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess'));
await loadTags(true);
emit('changed', { deletedTagId: tag.id });
void (async () => {
await new Promise((resolve) => setTimeout(resolve, 800));
emit('changed', { deletedTagId: tag.id });
})();
} catch (error: any) {
MessagePlugin.error(error?.message || t('common.operationFailed'));
}
};
watch(
() => props.visible,
(open) => {
if (open && props.kbId) {
void loadTags(true);
} else if (!open) {
resetLocalState();
}
},
);
watch(searchQuery, (newVal, oldVal) => {
if (newVal === oldVal || !props.visible || !props.kbId) return;
if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
void loadTags(true);
}, 300);
});
</script>
<style scoped lang="less">
.tag-manage-toolbar {
display: flex;
align-items: center;
gap: 6px;
}
.tag-manage-search-wrap {
flex: 1;
min-width: 0;
}
.tag-manage-search {
width: 100%;
:deep(.t-input) {
font-size: 13px;
background-color: var(--td-bg-color-secondarycontainer);
border-color: transparent;
border-radius: 6px;
box-shadow: none !important;
&:hover,
&:focus,
&.t-is-focused {
border-color: var(--td-component-border);
background-color: var(--td-bg-color-container);
box-shadow: none !important;
}
}
:deep(.t-input__inner) {
font-size: 13px;
}
:deep(.t-input__prefix-icon) {
margin-right: 0;
}
}
.tag-manage-create-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
padding: 0;
border-radius: 6px;
color: var(--td-text-color-secondary);
:deep(.t-icon) {
font-size: 16px;
}
&:hover:not(:disabled) {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
}
&:disabled {
opacity: 0.45;
}
}
.tag-manage-loading {
min-height: 80px;
}
.tag-manage-empty {
padding: 24px 0;
}
.tag-tile-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.tag-tile {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
min-height: 44px;
padding: 5px 6px 5px 8px;
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
background: var(--td-bg-color-container);
box-sizing: border-box;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover:not(.tag-tile--editing):not(.tag-tile--skeleton) {
border-color: var(--td-component-border);
background: color-mix(in srgb, var(--td-bg-color-secondarycontainer) 40%, var(--td-bg-color-container));
}
&--skeleton {
padding: 0;
border: none;
background: transparent;
}
&--editing {
border-color: var(--td-component-border);
background: var(--td-bg-color-secondarycontainer);
box-shadow: none;
.tag-tile__actions {
opacity: 1;
}
}
}
.tag-tile__main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.tag-tile__input {
flex: 1;
min-width: 0;
:deep(.t-input) {
background: transparent;
border-color: transparent;
box-shadow: none;
padding-left: 0;
padding-right: 0;
}
:deep(.t-input__wrap) {
background: transparent;
border-color: transparent;
box-shadow: none;
}
:deep(.t-input__inner) {
padding: 0;
font-size: 13px;
font-weight: 500;
}
:deep(.t-input:hover),
:deep(.t-input.t-is-focused),
:deep(.t-input__wrap:hover),
:deep(.t-input__wrap.t-is-focused) {
border-color: transparent !important;
box-shadow: none !important;
outline: none;
}
:deep(.t-input.t-is-focused .t-input__suffix),
:deep(.t-input.t-is-focused .t-input__prefix) {
box-shadow: none;
}
}
.tag-tile__badge {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-placeholder);
}
.tag-tile__text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.tag-tile__name {
font-size: 13px;
font-weight: 500;
line-height: 1.3;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-tile__count {
font-size: 11px;
line-height: 1.3;
color: var(--td-text-color-placeholder);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-tile__actions {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s ease;
}
.tag-tile:hover .tag-tile__actions,
.tag-tile:focus-within .tag-tile__actions,
.tag-tile__actions:focus-within {
opacity: 1;
}
@media (hover: none) {
.tag-tile__actions {
opacity: 1;
}
}
.tag-tile__action-btn {
padding: 0 2px;
&--confirm {
color: var(--td-text-color-secondary);
&:hover,
&:focus-visible {
color: var(--td-text-color-primary);
background: var(--td-bg-color-container);
}
}
}
.tag-load-more {
display: flex;
justify-content: center;
padding-top: 8px;
:deep(.t-button) {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
}
</style>
@@ -18,6 +18,9 @@ test('uses a compact flat dialog with selected and available sections', () => {
assert.match(component, /class="setting-drawer__section-title"/)
assert.match(component, /tagEditSelectedSection/)
assert.match(component, /tagEditAvailableSection/)
assert.match(component, /canManage/)
assert.match(component, /tagManageLink/)
assert.match(component, /open-manage/)
assert.match(component, /selectedTagsList/)
assert.match(component, /availableTagsList/)
assert.match(component, /class="tag-edit-chip"/)
@@ -29,7 +29,19 @@
</section>
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('knowledgeBase.tagEditAvailableSection') }}</h4>
<div class="tag-edit-section-head">
<h4 class="setting-drawer__section-title">{{ $t('knowledgeBase.tagEditAvailableSection') }}</h4>
<t-button
v-if="canManage"
variant="text"
size="small"
theme="default"
class="tag-edit-manage-link"
@click="handleOpenManage"
>
{{ $t('knowledgeBase.tagManageLink') }}
</t-button>
</div>
<div class="tag-edit-search-bar">
<t-input v-model="searchQuery" :placeholder="$t('knowledgeBase.tagEditSearch')" clearable size="small">
<template #prefix-icon>
@@ -93,12 +105,14 @@ const props = defineProps<{
kbId: string;
tagList: Tag[];
selectedTags: Tag[];
canManage?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'confirm', tagIds: string[]): void;
(e: 'tag-created'): void;
(e: 'open-manage'): void;
}>();
const { t } = useI18n();
@@ -212,6 +226,11 @@ async function handleConfirm() {
function handleClose() {
emit('update:visible', false);
}
function handleOpenManage() {
emit('update:visible', false);
emit('open-manage');
}
</script>
<style>
@@ -355,6 +374,18 @@ function handleClose() {
font-size: 12px;
color: var(--td-text-color-placeholder);
flex-shrink: 0;
border: none !important;
background: transparent !important;
box-shadow: none !important;
transition: color 0.15s ease;
}
.tag-edit-section-head :deep(.tag-edit-manage-link.t-button:hover),
.tag-edit-section-head :deep(.tag-edit-manage-link.t-button:focus-visible) {
color: var(--td-brand-color) !important;
background: transparent !important;
border-color: transparent !important;
text-decoration: none;
}
.tag-edit-search-bar {
+8 -1
View File
@@ -577,12 +577,19 @@ func (h *KnowledgeHandler) GetKnowledge(c *gin.Context) {
}
// Resolve knowledge and validate KB access (at least viewer)
knowledge, _, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleViewer)
knowledge, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleViewer)
if err != nil {
c.Error(err)
return
}
// Re-fetch with tenant-scoped service so tags and other joined fields are populated.
if knowledge, err = h.kgService.GetKnowledgeByID(effCtx, id); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewNotFoundError("Knowledge not found"))
return
}
logger.Infof(ctx, "Knowledge retrieved successfully, ID: %s, title: %s",
secutils.SanitizeForLog(knowledge.ID), secutils.SanitizeForLog(knowledge.Title))
c.JSON(http.StatusOK, gin.H{