Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,8 @@ System/飞书扩展及文件 ID 导入、策略协议需对应部署支持,
文档分页使用 ParamsPage/ResponsePage<DocumentItem>,名称/创建者/任务/启用/命中/标签筛选支持组合。
图片与文档共用 Document API 的 `getDocumentPage` 列表接口及响应结构,图片列表额外传入查询参数
`resource_type: 'image'`,不单独封装图片列表接口。
不分页选项复用 `getAllDocuments(knowledgeId, { resource_type })`,类型为 `document` 或 `image`,返回 `DocumentItem[]`;
工作空间、System 资源管理及 System 共享使用相同参数,关联分段弹窗据此切换左侧资源列表。
批量删除提交 `{ id_list, resource_type }`,图片传 `image`,普通文档默认传 `document`;
服务端按知识库和资源类型校验整批 ID。
批量向量化提交 `{ id_list, state_list, resource_type }`;图片同样传 `image`,普通文档默认传 `document`。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ import type {

const getPrefix = (knowledgeId: string) => `/system/resource/knowledge/${knowledgeId}/document`

/** 获取知识库不分页的文档选项。 */
const getAllDocuments = (knowledgeId: string) => get<DocumentItem[]>(getPrefix(knowledgeId))
/**
* 获取知识库不分页的文档或图片选项。
* TODO:联调 resource_type=document/image 筛选及图片缩略图字段 meta.source_file_id。
*/
const getAllDocuments = (knowledgeId: string, query?: { resource_type: DocumentResourceType }) => get<DocumentItem[]>(getPrefix(knowledgeId), query)

/** 获取文档分页列表,支持名称和创建者筛选。 */
const getDocumentPage = (knowledgeId: string, page: ParamsPage, query?: Dict<unknown>) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ import type {

const getPrefix = (knowledgeId: string) => `/system/shared/knowledge/${knowledgeId}/document`

/** 获取知识库不分页的文档选项。 */
const getAllDocuments = (knowledgeId: string) => get<DocumentItem[]>(getPrefix(knowledgeId))
/**
* 获取知识库不分页的文档或图片选项。
* TODO:联调 resource_type=document/image 筛选及图片缩略图字段 meta.source_file_id。
*/
const getAllDocuments = (knowledgeId: string, query?: { resource_type: DocumentResourceType }) => get<DocumentItem[]>(getPrefix(knowledgeId), query)

/** 获取文档分页列表,支持名称和创建者筛选。 */
const getDocumentPage = (knowledgeId: string, page: ParamsPage, query?: Dict<unknown>) =>
Expand Down
7 changes: 5 additions & 2 deletions ui/src/api/admin/workspace/knowledge/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ import { getWorkspaceId } from '@/utils/resource-context'

const getPrefix = (knowledgeId: string) => `/workspace/${getWorkspaceId()}/knowledge/${knowledgeId}/document`

/** 获取知识库不分页的文档选项。 */
const getAllDocuments = (knowledgeId: string) => get<DocumentItem[]>(getPrefix(knowledgeId))
/**
* 获取知识库不分页的文档或图片选项。
* TODO:联调 resource_type=document/image 筛选及图片缩略图字段 meta.source_file_id。
*/
const getAllDocuments = (knowledgeId: string, query?: { resource_type: DocumentResourceType }) => get<DocumentItem[]>(getPrefix(knowledgeId), query)

/** 获取文档分页列表,支持名称和创建者筛选。 */
const getDocumentPage = (knowledgeId: string, page: ParamsPage, query?: Dict<unknown>) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import type MkViewLayout from '@/components/global/mk-view-layout/index.vue'
import MkSearchList from '@/components/mk-search-list/index.vue'
import type { ParamsPage } from '@/api/admin/core/types'
import { DOCUMENT_RESOURCE_TYPE } from '@/api/enums'
import type { DocumentItem, DocumentResourceType, ParagraphItem, ProblemParagraph, ProblemParagraphReference } from '@/api/types'
import { getFileUrl } from '@/utils/common'
import { getFileIconUrl } from '@/utils/icon'
import { MsgSuccess } from '@/utils/message'
import { useKnowledgeDetailContext } from '@/views/knowledge-detail/context'

defineOptions({ name: 'AssociateProblemParagraphDialog' })
const emit = defineEmits<{ success: [] }>()
const { knowledgeId, resourceApi } = useKnowledgeDetailContext({ resourceScopes: ['workspace', 'system-resource', 'system-shared'] as const })

const visible = ref(false)
const documentLoading = ref(false)
const operationLoading = ref(false)
const problemIds = ref<string[]>([])
const documents = ref<DocumentItem[]>([])
const resourceType = ref<DocumentResourceType>(DOCUMENT_RESOURCE_TYPE.DOCUMENT)
const documentSearchText = ref('')
const selectedDocumentId = ref('')
const paragraphSearchText = ref('')
const paragraphs = ref<ParagraphItem[]>([])
const selectedParagraphs = ref<ProblemParagraphReference[]>([])
const infiniteScrollRef = useTemplateRef<{ reset: () => Promise<void> }>('infiniteScrollRef')
const paragraphLayoutRef = useTemplateRef<InstanceType<typeof MkViewLayout>>('paragraphLayoutRef')

const isBatchAssociation = computed(() => problemIds.value.length > 1)
const selectedDocument = computed(() => documents.value.find(({ id }) => id === selectedDocumentId.value))
const selectedParagraphIds = computed(() => new Set(selectedParagraphs.value.map(({ paragraph_id }) => paragraph_id)))

function getSelectedCount(documentId: string) {
return selectedParagraphs.value.filter(({ document_id }) => document_id === documentId).length
}

function isSelected(paragraphId: string) {
return selectedParagraphIds.value.has(paragraphId)
}

function loadDocuments() {
paragraphLayoutRef.value?.setScrollTop(0)
documents.value = []
selectedDocumentId.value = ''
documentSearchText.value = ''
paragraphSearchText.value = ''
paragraphs.value = []
return resourceApi.document.getAllDocuments(knowledgeId.value, { resource_type: resourceType.value }).then((documentList) => {
documents.value = documentList
selectedDocumentId.value = documentList[0]?.id ?? ''
})
}

function handleResourceTypeChange() {
documentLoading.value = true
return loadDocuments()
.catch(() => {
// 请求层统一提示错误,失败时保持当前类型的空列表。
})
.finally(() => {
documentLoading.value = false
})
}

function loadParagraphPage(pagination: ParamsPage) {
// TODO(图片联调):核对图片资源 id 能否复用 documentId 查询分段,以及分页结果中的 id、document_id、title、content。
return resourceApi.paragraph.getParagraphPage(knowledgeId.value, selectedDocumentId.value, pagination, {
title: paragraphSearchText.value.trim() || undefined,
})
}

function refreshParagraphs() {
paragraphLayoutRef.value?.setScrollTop(0)
return infiniteScrollRef.value?.reset()
}

function handleSelectDocument(document: DocumentItem) {
if (operationLoading.value || selectedDocumentId.value === document.id) return
selectedDocumentId.value = document.id
paragraphSearchText.value = ''
return refreshParagraphs()
}

function handleParagraphSearch() {
if (!selectedDocumentId.value || operationLoading.value) return
return refreshParagraphs()
}

function removeSelectedParagraph(paragraphId: string) {
selectedParagraphs.value = selectedParagraphs.value.filter(({ paragraph_id }) => paragraph_id !== paragraphId)
}

function addSelectedParagraph(paragraph: ParagraphItem) {
selectedParagraphs.value = [...selectedParagraphs.value, { paragraph_id: paragraph.id, document_id: paragraph.document_id }]
}

function handleToggleParagraph(paragraph: ParagraphItem) {
if (operationLoading.value) return
// 批量关联提交后不支持在此弹窗批量解除关联,避免只解除首个问题的关联。
if (isBatchAssociation.value && isSelected(paragraph.id)) return
const problemId = problemIds.value[0]
if (!problemId) return
operationLoading.value = true
const selected = isSelected(paragraph.id)
// TODO(图片联调):核对图片分段的关联及取消关联是否复用现有 document_id / paragraph_id 协议,成功后重新打开确认回显。
const request = selected
? resourceApi.paragraph.putUnassociateParagraphProblem(knowledgeId.value, paragraph.document_id, paragraph.id, problemId)
: resourceApi.problem.putBatchAssociateProblems(knowledgeId.value, {
problem_id_list: [...problemIds.value],
paragraph_list: [{ paragraph_id: paragraph.id, document_id: paragraph.document_id }],
})
return request
.then(() => {
if (selected) {
removeSelectedParagraph(paragraph.id)
} else {
addSelectedParagraph(paragraph)
}
MsgSuccess(selected ? '取消关联成功' : '关联成功')
emit('success')
})
.catch(() => {
// 请求层统一提示错误,失败时保留当前选择。
})
.finally(() => {
operationLoading.value = false
})
}

function handleBeforeClose(done: () => void) {
if (!operationLoading.value) done()
}

function handleClosed() {
problemIds.value = []
documents.value = []
documentSearchText.value = ''
selectedDocumentId.value = ''
paragraphSearchText.value = ''
paragraphs.value = []
selectedParagraphs.value = []
}

function open(targetProblemIds: string[]) {
if (visible.value || operationLoading.value || !targetProblemIds.length) return
const problemId = targetProblemIds[0]
if (!problemId) return
problemIds.value = [...targetProblemIds]
resourceType.value = DOCUMENT_RESOURCE_TYPE.DOCUMENT
visible.value = true
documentLoading.value = true
// TODO(图片联调):确认已关联分段查询包含图片分段,并返回 id、document_id,供选中态和关联数量回显。
const associatedParagraphs =
targetProblemIds.length === 1 ? resourceApi.problem.getProblemParagraphs(knowledgeId.value, problemId) : Promise.resolve<ProblemParagraph[]>([])
return Promise.all([loadDocuments(), associatedParagraphs])
.then(([, problemParagraphs]) => {
selectedParagraphs.value = problemParagraphs.map(({ id, document_id }) => ({ paragraph_id: id, document_id }))
})
.catch(() => {
// 请求层统一提示错误,失败时保持空列表。
})
.finally(() => {
documentLoading.value = false
})
}

defineExpose({ open })
</script>

<template>
<MkDialog v-model="visible" align-center class="mk-aside-content-dialog" width="1200" :before-close="handleBeforeClose" @closed="handleClosed">
<template #header="{ titleId }">
<h4 :id="titleId">关联分段</h4>
</template>
<MkViewLayout ref="paragraphLayoutRef" title="">
<template #aside>
<div v-loading="documentLoading" class="flex-column h-full min-h-0 pt-4" :inert="operationLoading">
<!-- 切换文档或图片资源 -->
<el-tabs
v-model="resourceType"
class="mx-4 mb-4"
:before-leave="() => !documentLoading && !operationLoading"
@tab-change="handleResourceTypeChange"
>
<el-tab-pane label="文档" :name="DOCUMENT_RESOURCE_TYPE.DOCUMENT" />
<el-tab-pane label="图片" :name="DOCUMENT_RESOURCE_TYPE.IMAGE" />
</el-tabs>
<!-- 搜索并切换候选分段所在资源 -->
<MkSearchList
v-model="documentSearchText"
:data="documents"
:default-active="selectedDocumentId"
:empty-text="resourceType === DOCUMENT_RESOURCE_TYPE.IMAGE ? '暂无图片' : '暂无文档'"
@click="handleSelectDocument"
>
<template #default="{ row }">
<!-- TODO 图片 -->
<el-image
v-if="resourceType === DOCUMENT_RESOURCE_TYPE.IMAGE"
:src="getFileUrl(row.meta?.source_file_id as string | undefined)"
class="mr-2 size-4 shrink-0 rounded"
:alt="row.name"
fit="cover"
/>
<img v-else :src="getFileIconUrl(row.name)" class="mr-2 size-4 shrink-0" alt="" />
<span class="min-w-0 flex-1 truncate" :title="row.name">{{ row.name }}</span>
<span v-if="getSelectedCount(row.id)" class="ml-1 flex-center h-4 min-w-4 shrink-0 rounded-full bg-primary px-1 text-xs text-white">
{{ getSelectedCount(row.id) }}
</span>
</template>
</MkSearchList>
</div>
</template>

<template #default="{ Header }">
<component :is="Header">
<span class="min-w-0 truncate" :title="selectedDocument?.name">{{ selectedDocument?.name || '-' }}</span>
<MkSearchInput v-model="paragraphSearchText" class="w-60! shrink-0" :disabled="!selectedDocument" @change="handleParagraphSearch" />
</component>
<div v-loading="operationLoading" class="flex-column min-h-0 flex-1">
<MkInfiniteScroll
v-if="selectedDocument && !documentLoading"
ref="infiniteScrollRef"
v-model="paragraphs"
class="flex-1"
:load="loadParagraphPage"
>
<div class="space-y-2">
<template v-for="paragraph in paragraphs" :key="paragraph.id">
<!-- 选择或取消选择当前分段 -->
<el-card
shadow="never"
class="mk-paragraph-card group relative cursor-pointer"
:class="{ active: isSelected(paragraph.id), disabled: operationLoading }"
@click="handleToggleParagraph(paragraph)"
>
<h4 class="mb-2 truncate" :title="paragraph.title || '-'">{{ paragraph.title || '-' }}</h4>
<p class="mb-3 line-clamp-2 break-words">{{ paragraph.content }}</p>
<span class="text-sm text-N500">{{ paragraph.content.length }} 字符</span>
<MkTooltip v-if="!isBatchAssociation && isSelected(paragraph.id)" content="取消关联" placement="top">
<!-- 取消当前问题与分段的关联 -->
<el-button
circle
class="group-hover-visible absolute top-2 right-2 shadow-md"
:disabled="operationLoading"
@click.stop="handleToggleParagraph(paragraph)"
>
<MkIcon name="icon_unlink_outlined" />
</el-button>
</MkTooltip>
</el-card>
</template>
</div>
<template #empty>
<MkEmpty :type="paragraphSearchText ? 'search' : 'default'" description="暂无分段" />
</template>
</MkInfiniteScroll>
</div>
</template>
</MkViewLayout>
<template #footer>
<div class="-mx-6 -mb-6 flex-align-center h-13.5 border-t px-6 py-4 text-sm text-N600">已关联 {{ selectedParagraphs.length }}</div>
</template>
</MkDialog>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getFileIconUrl } from '@/utils/icon'
defineOptions({ name: 'ProblemDetailDrawer' })
const problem = defineModel<ProblemItem>({ required: true })
defineProps<{ hasPrevious: boolean; hasNext: boolean }>()
const emit = defineEmits<{ previous: []; next: []; refresh: []; closed: [] }>()
const emit = defineEmits<{ previous: []; next: []; refresh: []; closed: []; associate: [] }>()
const { knowledgeId, resourceApi } = useKnowledgeDetailContext()
const { resourceApi: documentResourceApi } = useDocumentDetailContext()
const visible = ref(true)
Expand Down Expand Up @@ -139,8 +139,8 @@ watch(
<MkEmpty v-else-if="!loading" description="暂无关联分段" />
</div>
<template #footer>
<!-- 关联分段:TODO -->
<el-button plain>关联分段</el-button>
<!-- 关联当前问题的分段 -->
<el-button plain :disabled="loading" @click="emit('associate')">关联分段</el-button>
<!-- 查看上一个问题 -->
<el-button plain :disabled="loading || !hasPrevious" @click="emit('previous')">上一个</el-button>
<!-- 查看下一个问题 -->
Expand Down
Loading
Loading