mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
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:
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || []),
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "동기화되지 않음",
|
||||
|
||||
@@ -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: 'Не синхронизировано',
|
||||
|
||||
@@ -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: "未同步",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user