Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/documentCollection/KnowledgeShareManagement.vue
陈子默 0d14f1c165 feat: 支持知识库 CSV 大文件导入
- 增加 CSV 流式解析、表格语义分块和分页预览

- 增加快照两阶段清理、失败重试和格式校验

- 补充批量入口、管理端交互和回归测试
2026-08-07 13:11:59 +08:00

1516 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@easyflow/locales';
import { CopyDocument } from '@element-plus/icons-vue';
import { ElButton, ElCard, ElIcon, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
type EndpointEnumValue = {
description: string;
value: string;
};
type EndpointParam = {
enumValues?: readonly EndpointEnumValue[];
location: 'body' | 'header' | 'metadata' | 'multipart' | 'query';
name: string;
note?: string;
required?: boolean;
type?: string;
};
type EndpointExample = {
content: string;
title: string;
};
type EndpointDoc = {
contentType?: string;
examples?: EndpointExample[];
hint: string;
method: 'GET' | 'POST';
notes?: string[];
openByDefault?: boolean;
params: EndpointParam[];
path: string;
permission: '知识导入' | '知识库维护' | '知识库读取';
};
const props = defineProps({
knowledgeId: {
type: String,
required: true,
},
collectionType: {
type: String,
default: 'DOCUMENT',
},
manageable: {
type: Boolean,
default: true,
},
});
const router = useRouter();
const createLoading = ref(false);
const generatedUrl = ref('');
const generatedExpireAt = ref('');
const apiBaseUrl = computed(() => {
const appBasePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
if (typeof window === 'undefined') {
return `${appBasePath}/public-api/knowledge-share`;
}
return `${window.location.origin}${appBasePath}/public-api/knowledge-share`;
});
const formatJson = (value: unknown) => JSON.stringify(value, null, 2);
const retrievalModeEnumValues: readonly EndpointEnumValue[] = [
{ value: 'VECTOR', description: '向量语义召回' },
{ value: 'KEYWORD', description: '关键词全文召回' },
{ value: 'HYBRID', description: '混合召回,默认值' },
];
const chunkStrategyEnumValues: readonly EndpointEnumValue[] = [
{ value: 'AUTO', description: '自动选择分块策略,默认值' },
{ value: 'MARKDOWN_SECTION', description: '按 Markdown 标题层级分块' },
{ value: 'OUTLINE_SECTION', description: '按文档大纲或章节分块' },
{ value: 'QA_PAIR', description: '按问答对分块' },
{ value: 'PARAGRAPH_LENGTH', description: '按段落和长度分块' },
{ value: 'CUSTOM_REGEX', description: '按自定义正则表达式分块' },
];
const duplicatePolicyEnumValues: readonly EndpointEnumValue[] = [
{ value: 'SKIP', description: '跳过历史重复文件,默认值' },
{ value: 'OVERWRITE', description: '导入成功后覆盖历史文档' },
{ value: 'REIMPORT', description: '保留历史文档并重新导入一份' },
];
const importItemStatusEnumValues: readonly EndpointEnumValue[] = [
{ value: 'PENDING', description: '等待处理' },
{ value: 'UPLOADING', description: '文件正在上传' },
{ value: 'RUNNING', description: '正在处理' },
{ value: 'UPLOADED', description: '文件已上传' },
{ value: 'FAILED', description: '处理失败' },
{ value: 'COMPLETED', description: '处理完成' },
{ value: 'SKIPPED', description: '因重复而跳过' },
{ value: 'CANCELLED', description: '文件随未启动批次一并取消' },
];
const parameterTypes: Record<string, string> = {
answerHtml: 'string',
categoryId: 'string (ID)',
collectionId: 'string (ID)',
content: 'string',
documentId: 'string (ID)',
file: 'File',
fileKeys: 'string[]',
files: 'File[]',
id: 'string (ID)',
itemStatus: 'enum string',
keyword: 'string',
knowledgeId: 'string (ID)',
metadata: 'JSON',
'metadata.chunkStrategy': 'object',
'metadata.chunkStrategy.strategyCode': 'enum string',
'metadata.duplicatePolicy': 'enum string',
'metadata.files': 'object[]',
'metadata.files[].clientFileKey': 'string',
'metadata.files[].fileName': 'string',
'metadata.files[].relativePath': 'string',
'metadata.knowledgeId': 'string (ID)',
pageNumber: 'integer',
pageSize: 'integer',
question: 'string',
retrievalMode: 'enum string',
taskId: 'string (ID)',
};
const parameterDescriptions: Record<string, string> = {
answerHtml: 'FAQ 答案 HTML',
categoryId: 'FAQ 分类 ID',
collectionId: '知识库 ID',
content: '分块正文',
documentId: '文档 ID',
file: '待上传文件',
fileKeys: '需要重试的文件键',
files: '待导入文件列表',
id: '数据 ID',
itemStatus: '文件处理状态筛选',
keyword: '检索关键词',
knowledgeId: '知识库 ID',
metadata: '导入元数据与文件清单',
'metadata.chunkStrategy': '分块策略,省略时默认 AUTO',
'metadata.chunkStrategy.strategyCode': '分块策略编码',
'metadata.duplicatePolicy': '重复策略,默认 SKIP',
'metadata.files': '与文件 Part 一一对应的清单',
'metadata.files[].clientFileKey': '批次内唯一的文件键,最长 64 字符',
'metadata.files[].fileName': '文件名,须与对应文件 Part 一致',
'metadata.files[].relativePath': '文件夹内相对路径',
'metadata.knowledgeId': '目标知识库 ID',
pageNumber: '页码,默认 1',
pageSize: '每页条数',
question: 'FAQ 问题',
retrievalMode: '召回方式',
taskId: '导入任务 ID',
};
const parameterLocationLabels: Record<EndpointParam['location'], string> = {
body: 'JSON Body',
header: 'Header',
metadata: 'metadata JSON',
multipart: 'Multipart',
query: 'Query',
};
const buildJsonPostExample = (
path: string,
body: unknown,
extraHeaders: string[] = [],
) => {
const headers = [
" -H 'ApiKey: 你的访问令牌' \\",
...extraHeaders.map((header) => ` -H '${header}' \\`),
" -H 'Content-Type: application/json' \\",
];
return [
`curl -X POST '${apiBaseUrl.value}${path}' \\`,
...headers,
` --data '${formatJson(body)}'`,
].join('\n');
};
const detailExample = computed(() => {
return [
`curl -X GET '${apiBaseUrl.value}/detail?knowledgeId=${props.knowledgeId}&pageNumber=1&pageSize=50' \\`,
" -H 'ApiKey: 你的访问令牌'",
].join('\n');
});
const searchExample = computed(() => {
return [
`curl -G '${apiBaseUrl.value}/search' \\`,
" -H 'ApiKey: 你的访问令牌' \\",
` --data-urlencode 'knowledgeId=${props.knowledgeId}' \\`,
" --data-urlencode 'keyword=测试问题'",
].join('\n');
});
const endpointDocs = computed(() => {
const documentRemoveBody = {
knowledgeId: props.knowledgeId,
id: '文档ID',
};
const chunkUpdateBody = {
knowledgeId: props.knowledgeId,
id: '分块ID',
content: '更新后的分块内容',
};
const chunkRemoveBody = {
knowledgeId: props.knowledgeId,
id: '分块ID',
};
const faqSaveBody = {
collectionId: props.knowledgeId,
question: '如何申请账号?',
answerHtml: '<p>请联系管理员开通账号。</p>',
};
const faqUpdateBody = {
id: 'FAQ ID',
collectionId: props.knowledgeId,
question: '如何修改账号信息?',
answerHtml: '<p>请在个人中心修改。</p>',
};
const faqRemoveBody = {
id: 'FAQ ID',
};
const faqImportResponseExample = formatJson({
errorCode: 0,
message: '成功',
data: {
totalCount: 100,
successCount: 98,
errorCount: 2,
errorRows: [
{
rowNumber: 12,
categoryPath: '默认分类',
question: '示例问题',
reason: '问题不能为空或格式不正确',
},
],
},
});
const detailDocumentRecords =
props.collectionType === 'FAQ'
? []
: [
{
id: '文档ID',
title: 'manual.pdf',
documentType: 'pdf',
contentType: 'application/pdf',
processStatus: 'INDEXED',
chunkCount: 12,
progressPercent: 100,
created: '2026-08-02 16:30:00',
modified: '2026-08-02 16:31:00',
},
];
const detailResponseExample = formatJson({
errorCode: 0,
message: '成功',
data: {
id: props.knowledgeId,
title: '示例知识库',
collectionType: props.collectionType,
documents: {
pageNumber: 1,
pageSize: 50,
totalPage: detailDocumentRecords.length > 0 ? 1 : 0,
totalRow: detailDocumentRecords.length,
records: detailDocumentRecords,
},
},
});
const searchResponseExample = formatJson({
errorCode: 0,
message: '成功',
data:
props.collectionType === 'FAQ'
? [
{
resultType: 'FAQ',
faqId: 'FAQ ID',
question: '如何申请账号?',
answerText: '请联系管理员开通账号。',
categoryId: 'FAQ 分类 ID',
content: '问题:如何申请账号?\n答案请联系管理员开通账号。',
score: 0.8732,
hitSource: 'HYBRID',
},
]
: [
{
resultType: 'DOCUMENT',
documentId: '文档ID',
documentName: 'manual.pdf',
sourceFileName: 'manual.pdf',
content: '命中的文档分块内容',
score: 0.8732,
hitSource: 'HYBRID',
},
],
});
const retryBody = {
taskId: '导入接口返回的 taskId',
fileKeys: ['file-1'],
};
const importMetadata = {
knowledgeId: props.knowledgeId,
chunkStrategy: {
strategyCode: 'AUTO',
},
duplicatePolicy: 'SKIP',
files: [
{
clientFileKey: 'file-1',
fileName: 'manual.pdf',
relativePath: 'docs/manual.pdf',
},
],
};
const importCurlExample = [
"FILE='./docs/manual.pdf'",
`KNOWLEDGE_ID='${props.knowledgeId}'`,
'FILE_NAME=$(basename "$FILE")',
'METADATA=$(printf \'{"knowledgeId":"%s","chunkStrategy":{"strategyCode":"AUTO"},"duplicatePolicy":"SKIP","files":[{"clientFileKey":"file-1","fileName":"%s","relativePath":"%s"}]}\' "$KNOWLEDGE_ID" "$FILE_NAME" "$FILE_NAME")',
'',
`curl -X POST '${apiBaseUrl.value}/document/import/batch' \\`,
" -H 'ApiKey: 你的访问令牌' \\",
' -F "metadata=$METADATA;type=application/json" \\',
' -F "files=@$FILE"',
].join('\n');
const importResponseExample = formatJson({
errorCode: 0,
message: '成功',
data: {
taskId: '异步任务ID',
status: 'RUNNING',
totalCount: 1,
totalBytes: 123_456,
createdAt: '2026-08-02T16:30:00+08:00',
},
});
const importStatusExample = [
`curl -G '${apiBaseUrl.value}/document/import/batch/status' \\`,
" -H 'ApiKey: 你的访问令牌' \\",
" --data-urlencode 'taskId=导入接口返回的 taskId' \\",
" --data-urlencode 'pageNumber=1' \\",
" --data-urlencode 'pageSize=20'",
].join('\n');
const importStatusResponseExample = formatJson({
errorCode: 0,
message: '成功',
data: {
taskId: '异步任务ID',
knowledgeId: props.knowledgeId,
status: 'PARTIAL_SUCCEEDED',
progressPercent: 100,
counts: {
total: 2,
completed: 1,
processing: 0,
pending: 0,
failed: 1,
skipped: 0,
retryableFailed: 1,
},
canRetry: true,
items: {
pageNumber: 1,
pageSize: 20,
total: 2,
records: [
{
fileKey: 'file-1',
relativePath: 'docs/manual.pdf',
documentId: '文档ID',
stage: 'PARSE',
status: 'FAILED',
attemptCount: 1,
retryable: true,
error: {
code: 'parse_service_unavailable',
message: '文档解析服务暂时不可用',
},
},
],
},
},
});
const retryResponseExample = formatJson({
errorCode: 0,
message: '成功',
data: {
taskId: '异步任务ID',
status: 'RUNNING',
retriedCount: 1,
},
});
const commonEndpoints: EndpointDoc[] = [
{
method: 'GET',
path: '/detail',
hint: '获取知识库详情',
permission: '知识库读取',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'pageNumber', location: 'query', note: '默认 1' },
{ name: 'pageSize', location: 'query', note: '默认 50最大 100' },
],
notes: [
'知识库原有字段保持在 data 顶层documents 返回已上传文档的分页摘要。',
'文档摘要包含 ID、标题、类型、处理状态、分块数、处理进度和时间FAQ 知识库返回空文档页。',
],
examples: [
{ title: 'cURL 示例', content: detailExample.value },
{ title: '响应示例', content: detailResponseExample },
],
},
{
method: 'GET',
path: '/search',
hint: '知识检索',
permission: '知识库读取',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'keyword', location: 'query', required: true },
{
name: 'retrievalMode',
location: 'query',
enumValues: retrievalModeEnumValues,
note: '召回方式,不传时默认 HYBRID',
},
],
notes: [
'resultType 用于区分 DOCUMENT 与 FAQ。',
'文档命中返回 documentId、documentNameFAQ 命中返回 faqId、question、answerText、categoryId。',
'sourceFileName 作为文档名称兼容字段继续保留FAQ 完整 HTML 可通过 /faq/detail 查询。',
],
examples: [
{ title: 'cURL 示例', content: searchExample.value },
{ title: '响应示例', content: searchResponseExample },
],
},
];
const typeEndpoints: EndpointDoc[] =
props.collectionType === 'FAQ'
? [
{
method: 'GET',
path: '/faq/page',
hint: 'FAQ 分页',
permission: '知识库读取',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'question', location: 'query', note: '问题关键字' },
{ name: 'categoryId', location: 'query', note: '分类 ID' },
{ name: 'pageNumber', location: 'query', note: '默认 1' },
{ name: 'pageSize', location: 'query', note: '默认 10' },
],
},
{
method: 'GET',
path: '/faq/detail',
hint: 'FAQ 详情',
permission: '知识库读取',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'id', location: 'query', required: true, note: 'FAQ ID' },
],
},
{
method: 'POST',
path: '/faq/save',
hint: '新增 FAQ',
permission: '知识库维护',
contentType: 'application/json',
params: [
{ name: 'collectionId', location: 'body', required: true },
{ name: 'question', location: 'body', required: true },
{ name: 'answerHtml', location: 'body', required: true },
{ name: 'categoryId', location: 'body', note: '分类 ID' },
],
examples: [
{ title: '请求体示例', content: formatJson(faqSaveBody) },
{
title: 'cURL 示例',
content: buildJsonPostExample('/faq/save', faqSaveBody),
},
],
},
{
method: 'POST',
path: '/faq/update',
hint: '修改 FAQ',
permission: '知识库维护',
contentType: 'application/json',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'id', location: 'body', required: true, note: 'FAQ ID' },
{
name: 'collectionId',
location: 'body',
note: '不传时保持原知识库 ID',
},
{ name: 'question', location: 'body', required: true },
{ name: 'answerHtml', location: 'body', required: true },
{ name: 'categoryId', location: 'body', note: '分类 ID' },
],
examples: [
{ title: '请求体示例', content: formatJson(faqUpdateBody) },
{
title: 'cURL 示例',
content: buildJsonPostExample(
`/faq/update?knowledgeId=${props.knowledgeId}`,
faqUpdateBody,
),
},
],
},
{
method: 'POST',
path: '/faq/remove',
hint: '删除 FAQ',
permission: '知识库维护',
contentType: 'application/json',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'id', location: 'body', required: true, note: 'FAQ ID' },
],
examples: [
{ title: '请求体示例', content: formatJson(faqRemoveBody) },
{
title: 'cURL 示例',
content: buildJsonPostExample(
`/faq/remove?knowledgeId=${props.knowledgeId}`,
faqRemoveBody,
),
},
],
},
{
method: 'POST',
path: '/faq/importExcel',
hint: '导入 FAQ Excel',
permission: '知识导入',
contentType: 'multipart/form-data',
params: [
{
name: 'collectionId',
location: 'multipart',
required: true,
},
{ name: 'file', location: 'multipart', required: true },
],
notes: [
'仅支持 xlsx、xls 文件,单个文件不超过 10 MB最多导入 5000 条。',
'请先下载导入模板并保持模板列结构。',
'由调用端自动生成 multipart boundary不要手动设置 Content-Type 请求头。',
],
examples: [
{
title: 'cURL 示例',
content: [
`curl -X POST '${apiBaseUrl.value}/faq/importExcel' \\`,
" -H 'ApiKey: 你的访问令牌' \\",
` -F 'collectionId=${props.knowledgeId}' \\`,
" -F 'file=@./faq_import.xlsx'",
].join('\n'),
},
{
title: '响应示例',
content: faqImportResponseExample,
},
],
},
{
method: 'GET',
path: '/faq/downloadImportTemplate',
hint: '下载 FAQ 导入模板',
permission: '知识导入',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
],
},
{
method: 'GET',
path: '/faq/exportExcel',
hint: '导出 FAQ Excel',
permission: '知识库读取',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
],
},
]
: [
{
method: 'GET',
path: '/document/page',
hint: '文档分页',
permission: '知识库读取',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'documentId', location: 'query', note: '文档 ID' },
{ name: 'pageNumber', location: 'query', note: '默认 1' },
{ name: 'pageSize', location: 'query', note: '默认 10' },
],
},
{
method: 'GET',
path: '/document/download',
hint: '下载文档',
permission: '知识库读取',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'documentId', location: 'query', required: true },
],
},
{
method: 'GET',
path: '/documentChunk/page',
hint: '文档分块分页',
permission: '知识库读取',
params: [
{ name: 'knowledgeId', location: 'query', required: true },
{ name: 'documentId', location: 'query', required: true },
{ name: 'pageNumber', location: 'query', note: '默认 1' },
{ name: 'pageSize', location: 'query', note: '默认 10' },
],
},
{
method: 'POST',
path: '/document/import/batch',
hint: '批量异步导入',
permission: '知识导入',
contentType: 'multipart/form-data',
openByDefault: true,
params: [
{
name: 'metadata',
location: 'multipart',
type: 'JSON',
required: true,
note: 'application/json含知识库 ID、文件清单和可选分块策略',
},
{
name: 'metadata.knowledgeId',
location: 'metadata',
required: true,
},
{
name: 'metadata.chunkStrategy',
location: 'metadata',
},
{
name: 'metadata.chunkStrategy.strategyCode',
location: 'metadata',
enumValues: chunkStrategyEnumValues,
note: '分块策略编码,不传时默认 AUTO',
},
{
name: 'metadata.duplicatePolicy',
location: 'metadata',
enumValues: duplicatePolicyEnumValues,
note: '重复文件处理策略,不传时默认 SKIP',
},
{
name: 'metadata.files',
location: 'metadata',
required: true,
},
{
name: 'metadata.files[].clientFileKey',
location: 'metadata',
required: true,
},
{
name: 'metadata.files[].fileName',
location: 'metadata',
required: true,
},
{
name: 'metadata.files[].relativePath',
location: 'metadata',
note: '可省略,默认使用 fileName',
},
{
name: 'files',
location: 'multipart',
type: 'File[]',
required: true,
note: '同名多文件 Part总计不超过 200 MiB',
},
],
notes: [
'metadata 是 Content-Type 为 application/json 的 multipart Part不是普通表单字符串。',
'metadata.knowledgeId 填当前知识库 IDchunkStrategy 可省略duplicatePolicy 默认 SKIP。',
'metadata.files 中每项填写 clientFileKey、fileName 和可选 relativePath文件大小由服务端实际读取并统计。',
'支持 txt、md、pdf、docx、pptx、xlsx、csv单文件不超过 100 MiB单批不超过 200 MiB最多 200 个文件。',
'metadata 最大 1 MiBmetadata.files 必须与 files Part 数量、顺序和文件名一致。',
'chunkStrategy 可不传,默认 AUTOduplicatePolicy 支持 SKIP、OVERWRITE、REIMPORT。',
'服务端会按调用者、元数据和文件内容生成请求指纹,任务完成后 10 分钟内的重复提交会返回原 taskId。',
'由调用端自动生成 multipart boundary不要手动设置 Content-Type 请求头。',
],
examples: [
{
title: 'metadata Part 填写示例',
content: formatJson(importMetadata),
},
{ title: '可执行 cURL 示例', content: importCurlExample },
{
title: 'HTTP 202 响应示例',
content: importResponseExample,
},
],
},
{
method: 'GET',
path: '/document/import/batch/status',
hint: '查询导入状态',
permission: '知识导入',
params: [
{ name: 'taskId', location: 'query', required: true },
{
name: 'itemStatus',
location: 'query',
enumValues: importItemStatusEnumValues,
note: '文件处理状态筛选,不传时查询全部状态',
},
{ name: 'pageNumber', location: 'query', note: '默认 1' },
{
name: 'pageSize',
location: 'query',
note: '默认 20最大 100',
},
],
notes: [
'公开状态包括 QUEUED、RUNNING、SUCCEEDED、FAILED、PARTIAL_SUCCEEDED、INTERRUPTED、CANCELLED进入终态后停止轮询。',
'只有 canRetry=true 的任务允许重试。',
],
examples: [
{ title: 'cURL 示例', content: importStatusExample },
{
title: '响应示例',
content: importStatusResponseExample,
},
],
},
{
method: 'POST',
path: '/document/import/batch/retry',
hint: '重试异常文件',
permission: '知识导入',
contentType: 'application/json',
params: [
{ name: 'taskId', location: 'body', required: true },
{
name: 'fileKeys',
location: 'body',
type: 'string[]',
note: '省略时重试全部;最多 200 个,每项最长 64 字符',
},
],
notes: [
'省略 fileKeys 时重试全部可恢复失败项;指定时只重试对应文件。',
'fileKeys 不允许传空数组、空值、重复值或超过 64 字符的值,最多 200 个。',
'任务正在运行时不能重复领取重试,请继续使用 taskId 查询状态。',
],
examples: [
{ title: '请求体示例', content: formatJson(retryBody) },
{
title: 'cURL 示例',
content: buildJsonPostExample(
'/document/import/batch/retry',
retryBody,
),
},
{ title: '响应示例', content: retryResponseExample },
],
},
{
method: 'POST',
path: '/document/remove',
hint: '删除文档',
permission: '知识库维护',
contentType: 'application/json',
params: [
{ name: 'knowledgeId', location: 'body', required: true },
{ name: 'id', location: 'body', required: true, note: '文档 ID' },
],
examples: [
{ title: '请求体示例', content: formatJson(documentRemoveBody) },
{
title: 'cURL 示例',
content: buildJsonPostExample(
'/document/remove',
documentRemoveBody,
),
},
],
},
{
method: 'POST',
path: '/documentChunk/update',
hint: '更新文档分块',
permission: '知识库维护',
contentType: 'application/json',
params: [
{ name: 'knowledgeId', location: 'body', required: true },
{ name: 'id', location: 'body', required: true, note: '分块 ID' },
{ name: 'content', location: 'body', required: true },
],
examples: [
{ title: '请求体示例', content: formatJson(chunkUpdateBody) },
{
title: 'cURL 示例',
content: buildJsonPostExample(
'/documentChunk/update',
chunkUpdateBody,
),
},
],
},
{
method: 'POST',
path: '/documentChunk/remove',
hint: '删除文档分块',
permission: '知识库维护',
contentType: 'application/json',
params: [
{ name: 'knowledgeId', location: 'body', required: true },
{ name: 'id', location: 'body', required: true, note: '分块 ID' },
],
examples: [
{ title: '请求体示例', content: formatJson(chunkRemoveBody) },
{
title: 'cURL 示例',
content: buildJsonPostExample(
'/documentChunk/remove',
chunkRemoveBody,
),
},
],
},
];
return [...commonEndpoints, ...typeEndpoints].map((item) => ({
...item,
url: `${apiBaseUrl.value}${item.path}`,
}));
});
const getParameterType = (param: EndpointParam) =>
param.type || parameterTypes[param.name] || 'string';
const getParameterDescription = (param: EndpointParam) =>
param.note || parameterDescriptions[param.name] || '接口参数';
const createShare = async () => {
if (!props.manageable) {
ElMessage.warning($t('documentCollection.managePermissionHint'));
return;
}
createLoading.value = true;
try {
const res = await api.post('/api/v1/knowledgeShare/url/create', {
knowledgeId: props.knowledgeId,
});
if (res.errorCode === 0) {
const shareKey = String(res.data?.shareKey || '').trim();
generatedUrl.value = shareKey
? buildAbsoluteAppRouteUrl(
router.resolve({
name: 'KnowledgeShare',
query: { shareKey },
}).href,
)
: '';
generatedExpireAt.value = res.data?.expiresAt || '';
ElMessage.success('已创建分享链接');
}
} finally {
createLoading.value = false;
}
};
const copyGeneratedUrl = async () => {
if (!generatedUrl.value) {
ElMessage.warning('请先生成分享链接');
return;
}
await copyTextWithFeedback(generatedUrl.value, '已复制分享链接');
};
const copyApiExample = async (content: string) => {
await copyTextWithFeedback(content, '已复制调用示例');
};
const copyEndpointUrl = async (url: string) => {
await copyTextWithFeedback(url, '已复制接口地址');
};
</script>
<template>
<div class="share-management">
<ElCard shadow="never" class="share-card">
<template #header>
<div class="share-card__header">
<div>
<div class="share-card__title">当前分享链接</div>
<div class="share-card__desc">
默认有效期 30 分钟重新生成后旧链接立即失效
</div>
</div>
<ElButton
type="primary"
:loading="createLoading"
:disabled="!props.manageable"
@click="createShare"
>
{{ generatedUrl ? '重新生成链接' : '生成分享链接' }}
</ElButton>
</div>
</template>
<div v-if="generatedUrl" class="share-result">
<div class="share-result__row">
<ElInput v-model="generatedUrl" readonly />
<ElButton
text
circle
:icon="CopyDocument"
aria-label="复制分享链接"
title="复制分享链接"
@click="copyGeneratedUrl"
/>
</div>
<div class="share-result__meta">过期时间{{ generatedExpireAt }}</div>
</div>
<div v-else class="share-empty">暂未生成分享链接</div>
</ElCard>
<ElCard shadow="never" class="share-card">
<template #header>
<div class="share-card__header share-card__header--stack">
<div>
<div class="share-card__title">API 调用方式</div>
<div class="share-card__desc">
使用已开通对应知识库权限的访问令牌通过
<code>ApiKey</code> 请求头调用示例已自动带入当前知识库 ID
</div>
</div>
</div>
</template>
<div class="api-doc">
<div class="api-doc__meta">
<div class="api-doc__item">
<span class="api-doc__label">接口前缀</span>
<code class="api-doc__value">{{ apiBaseUrl }}</code>
</div>
<div class="api-doc__item">
<span class="api-doc__label">请求头</span>
<code class="api-doc__value">ApiKey: 你的访问令牌</code>
</div>
<div class="api-doc__item">
<span class="api-doc__label">JSON 请求</span>
<code class="api-doc__value">Content-Type: application/json</code>
</div>
<div class="api-doc__item">
<span class="api-doc__label">文件上传</span>
<code class="api-doc__value">multipart/form-data</code>
<span class="api-doc__helper">由调用端自动生成 boundary</span>
</div>
<div class="api-doc__item">
<span class="api-doc__label">当前知识库 ID</span>
<code class="api-doc__value">{{ props.knowledgeId }}</code>
</div>
</div>
<div
v-if="props.collectionType === 'DOCUMENT'"
class="api-doc__section api-doc__import-flow"
>
<div class="api-doc__section-title">批量导入调用流程</div>
<ol class="api-doc__steps">
<li>提交 multipart 请求保存 HTTP 202 响应中的 taskId</li>
<li>使用 taskId 查询任务状态按需分页查看每个文件的结果</li>
<li>
canRetry=true 使用 taskId 重试全部异常文件或通过 fileKeys
指定部分文件
</li>
</ol>
</div>
<div class="api-doc__section">
<div class="api-doc__section-title">接口列表</div>
<div class="api-doc__endpoint-list">
<div
v-for="endpoint in endpointDocs"
:key="endpoint.path"
class="api-doc__endpoint"
>
<div class="api-doc__endpoint-main">
<span class="api-doc__method">{{ endpoint.method }}</span>
<code>{{ endpoint.path }}</code>
<span class="api-doc__hint">{{ endpoint.hint }}</span>
<span class="api-doc__permission">{{
endpoint.permission
}}</span>
<ElButton
text
circle
:icon="CopyDocument"
class="api-doc__copy"
:aria-label="`复制 ${endpoint.path}`"
:title="`复制 ${endpoint.url}`"
@click="copyEndpointUrl(endpoint.url)"
/>
</div>
<div class="api-doc__parameter-table-wrap">
<table
class="api-doc__parameter-table"
:aria-label="`${endpoint.hint}参数说明`"
>
<thead>
<tr>
<th scope="col">参数</th>
<th scope="col">位置</th>
<th scope="col">类型</th>
<th scope="col">必填</th>
<th scope="col">说明</th>
</tr>
</thead>
<tbody>
<tr
v-for="param in endpoint.params"
:key="`${endpoint.path}-${param.location}-${param.name}`"
>
<td>
<code>{{ param.name }}</code>
</td>
<td>{{ parameterLocationLabels[param.location] }}</td>
<td>{{ getParameterType(param) }}</td>
<td>{{ param.required ? '是' : '否' }}</td>
<td>
<div>{{ getParameterDescription(param) }}</div>
<ul
v-if="param.enumValues?.length"
class="api-doc__enum-list"
>
<li
v-for="enumValue in param.enumValues"
:key="enumValue.value"
>
<code>{{ enumValue.value }}</code>
<span>{{ enumValue.description }}</span>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
<details
v-if="
endpoint.contentType ||
endpoint.notes?.length ||
endpoint.examples?.length
"
class="api-doc__endpoint-details"
:open="endpoint.openByDefault"
>
<summary>
{{
endpoint.openByDefault
? '必看metadata、文件 Part 与完整示例'
: '查看请求与响应说明'
}}
</summary>
<div class="api-doc__endpoint-details-content">
<div
v-if="endpoint.contentType"
class="api-doc__content-type"
>
<span>Content-Type</span>
<code>{{ endpoint.contentType }}</code>
</div>
<ul v-if="endpoint.notes?.length" class="api-doc__notes">
<li v-for="note in endpoint.notes" :key="note">
{{ note }}
</li>
</ul>
<div
v-for="example in endpoint.examples"
:key="`${endpoint.path}-${example.title}`"
class="api-doc__example"
>
<div class="api-doc__example-title">
{{ example.title }}
</div>
<div class="api-doc__code-wrap">
<button
type="button"
class="api-doc__code-copy"
:title="`复制${example.title}`"
:aria-label="`复制${endpoint.hint}${example.title}`"
@click="copyApiExample(example.content)"
>
<ElIcon><CopyDocument /></ElIcon>
</button>
<pre class="api-doc__code">{{ example.content }}</pre>
</div>
</div>
</div>
</details>
</div>
</div>
</div>
<div class="api-doc__section">
<div class="api-doc__section-title">详情示例</div>
<div class="api-doc__code-wrap">
<button
type="button"
class="api-doc__code-copy"
title="复制详情示例"
aria-label="复制详情示例"
@click="copyApiExample(detailExample)"
>
<ElIcon><CopyDocument /></ElIcon>
</button>
<pre class="api-doc__code">{{ detailExample }}</pre>
</div>
</div>
<div class="api-doc__section">
<div class="api-doc__section-title">检索示例</div>
<div class="api-doc__code-wrap">
<button
type="button"
class="api-doc__code-copy"
title="复制检索示例"
aria-label="复制检索示例"
@click="copyApiExample(searchExample)"
>
<ElIcon><CopyDocument /></ElIcon>
</button>
<pre class="api-doc__code">{{ searchExample }}</pre>
</div>
</div>
</div>
</ElCard>
</div>
</template>
<style scoped>
.share-management {
display: grid;
gap: 16px;
}
.share-card {
border-radius: 16px;
}
.share-card__header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.share-card__header--stack {
align-items: flex-start;
}
.share-card__title {
font-size: 16px;
font-weight: 600;
}
.share-card__desc,
.share-result__meta {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.share-result {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.share-result__row {
display: flex;
gap: 12px;
align-items: center;
}
.share-empty {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.api-doc {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
}
.api-doc__meta {
display: grid;
gap: 10px;
}
.api-doc__item {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.api-doc__label {
min-width: 84px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.api-doc__value {
padding: 4px 10px;
font-size: 13px;
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
border-radius: 10px;
}
.api-doc__helper {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.api-doc__section {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
min-width: 0;
}
.api-doc__import-flow {
padding: 16px;
background: var(--el-fill-color-extra-light);
border-radius: var(--el-border-radius-base);
}
.api-doc__steps {
display: grid;
gap: 8px;
padding-left: 24px;
margin: 0;
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
}
.api-doc__section-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.api-doc__endpoint-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
min-width: 0;
}
.api-doc__endpoint {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
min-width: 0;
padding: 8px 0;
}
.api-doc__endpoint-main {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
font-size: 13px;
}
.api-doc__copy {
flex: 0 0 auto;
}
.api-doc__method {
min-width: 42px;
font-weight: 600;
color: var(--el-color-primary);
}
.api-doc__hint {
color: var(--el-text-color-secondary);
}
.api-doc__permission {
padding: 2px 8px;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-radius: 999px;
}
.api-doc__parameter-table-wrap {
width: 100%;
max-width: 100%;
min-width: 0;
overflow-x: auto;
}
.api-doc__parameter-table {
width: 100%;
min-width: 640px;
font-size: 12px;
color: var(--el-text-color-regular);
table-layout: fixed;
border-spacing: 0;
border-collapse: separate;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
}
.api-doc__parameter-table th,
.api-doc__parameter-table td {
padding: 8px 12px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.api-doc__parameter-table th {
font-weight: 500;
color: var(--el-text-color-secondary);
white-space: nowrap;
background: var(--el-fill-color-extra-light);
}
.api-doc__parameter-table th:first-child,
.api-doc__parameter-table td:first-child {
width: 28%;
}
.api-doc__parameter-table th:nth-child(2),
.api-doc__parameter-table td:nth-child(2) {
width: 14%;
white-space: nowrap;
}
.api-doc__parameter-table th:nth-child(3),
.api-doc__parameter-table td:nth-child(3) {
width: 12%;
white-space: nowrap;
}
.api-doc__parameter-table th:nth-child(4),
.api-doc__parameter-table td:nth-child(4) {
width: 8%;
white-space: nowrap;
}
.api-doc__parameter-table tbody tr:last-child td {
border-bottom: none;
}
.api-doc__parameter-table td:last-child {
overflow-wrap: anywhere;
}
.api-doc__parameter-table code {
white-space: normal;
overflow-wrap: anywhere;
}
.api-doc__enum-list {
display: grid;
gap: 4px;
padding: 0;
margin: 6px 0 0;
list-style: none;
}
.api-doc__enum-list li {
display: flex;
gap: 8px;
align-items: flex-start;
}
.api-doc__enum-list code {
flex: 0 0 auto;
color: var(--el-text-color-primary);
}
.api-doc__endpoint-details {
margin-top: 4px;
font-size: 13px;
}
.api-doc__endpoint-details > summary {
width: fit-content;
color: var(--el-color-primary);
cursor: pointer;
}
.api-doc__endpoint-details > summary:focus-visible {
border-radius: var(--el-border-radius-small);
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.api-doc__endpoint-details-content {
display: grid;
gap: 16px;
padding-top: 16px;
}
.api-doc__content-type {
display: flex;
gap: 8px;
align-items: center;
color: var(--el-text-color-secondary);
}
.api-doc__content-type code {
color: var(--el-text-color-primary);
}
.api-doc__notes {
display: grid;
gap: 8px;
padding-left: 24px;
margin: 0;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.api-doc__example {
display: grid;
gap: 8px;
}
.api-doc__example-title {
font-weight: 500;
color: var(--el-text-color-primary);
}
.api-doc__code {
margin: 0;
padding: 14px 16px;
overflow-x: auto;
font-size: 12px;
line-height: 1.7;
color: var(--el-text-color-primary);
white-space: pre;
background: var(--el-fill-color-light);
border-radius: 14px;
}
.api-doc__code-wrap {
position: relative;
}
.api-doc__code-copy {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: none;
border-radius: 8px;
}
.api-doc__code-copy:hover {
color: var(--el-color-primary);
background: var(--el-fill-color);
}
.api-doc__code-copy:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.api-doc__code-wrap .api-doc__code {
padding-top: 40px;
}
</style>