feat: 收口知识库分享链路
- 新增 shareKey 单参数 URL 分享页与失效页 - 新增知识库分享后端鉴权、审计与迁移脚本 - 在访问令牌中增加知识库分享授权入口
This commit is contained in:
82
easyflow-ui-admin/app/src/api/knowledge-share.ts
Normal file
82
easyflow-ui-admin/app/src/api/knowledge-share.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { api } from '#/api/request';
|
||||
|
||||
const EXPIRED_ERROR_CODES = new Set([4601, 4602]);
|
||||
const SHARE_ERROR_REASON: Record<number, string> = {
|
||||
4601: 'expired',
|
||||
4602: 'invalid',
|
||||
};
|
||||
const APP_BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||
|
||||
function getShareParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const shareKey = params.get('shareKey') || '';
|
||||
return { shareKey };
|
||||
}
|
||||
|
||||
function appendShareQuery(url: string, extraParams?: Record<string, any>) {
|
||||
const base =
|
||||
typeof window === 'undefined' ? 'http://localhost' : window.location.origin;
|
||||
const target = new URL(url, base);
|
||||
const { shareKey } = getShareParams();
|
||||
if (shareKey) {
|
||||
target.searchParams.set('shareKey', shareKey);
|
||||
}
|
||||
Object.entries(extraParams || {}).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
target.searchParams.set(key, String(value));
|
||||
});
|
||||
return `${target.pathname}${target.search}`;
|
||||
}
|
||||
|
||||
function redirectIfExpired(response: any) {
|
||||
const errorCode = Number(response?.errorCode);
|
||||
if (!response || !EXPIRED_ERROR_CODES.has(errorCode)) {
|
||||
return response;
|
||||
}
|
||||
const reason = SHARE_ERROR_REASON[errorCode] || 'expired';
|
||||
window.location.assign(
|
||||
`${APP_BASE_PATH}/share/knowledge/expired?reason=${reason}`,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const knowledgeShareApi = {
|
||||
get(url: string, config?: Record<string, any>) {
|
||||
return api
|
||||
.get(appendShareQuery(url, config?.params), {
|
||||
...config,
|
||||
params: undefined,
|
||||
})
|
||||
.then(redirectIfExpired);
|
||||
},
|
||||
post(url: string, data?: any, config?: Record<string, any>) {
|
||||
return api
|
||||
.post(appendShareQuery(url), data, config)
|
||||
.then(redirectIfExpired);
|
||||
},
|
||||
postFile(url: string, data?: any, config?: Record<string, any>) {
|
||||
return api
|
||||
.postFile(appendShareQuery(url), data, config)
|
||||
.then(redirectIfExpired);
|
||||
},
|
||||
upload(url: string, data?: any, config?: Record<string, any>) {
|
||||
return api
|
||||
.upload(appendShareQuery(url), data, config)
|
||||
.then(redirectIfExpired);
|
||||
},
|
||||
download(url: string, config?: Record<string, any>) {
|
||||
return api.download(appendShareQuery(url, config?.params), {
|
||||
...config,
|
||||
params: undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function buildKnowledgeShareUrl(
|
||||
url: string,
|
||||
extraParams?: Record<string, any>,
|
||||
) {
|
||||
return appendShareQuery(url, extraParams);
|
||||
}
|
||||
@@ -12,6 +12,12 @@
|
||||
"save": "Save Configuration"
|
||||
},
|
||||
"engineHint": "The keyword search engine is controlled by platform-level configuration and is no longer configured per knowledge base.",
|
||||
"retrievalModeTitle": "Retrieval Mode Guide",
|
||||
"retrievalModeDescriptions": {
|
||||
"hybrid": "Combines semantic vector recall with keyword matching for balanced default retrieval.",
|
||||
"vector": "Focuses on semantic similarity and works better when query wording differs from the source text.",
|
||||
"keyword": "Focuses on literal term matching and is suitable for precise lookups such as terms, IDs, and names."
|
||||
},
|
||||
"message": {
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Configuration saved failed"
|
||||
|
||||
@@ -14,5 +14,6 @@
|
||||
"failure": "Failure"
|
||||
},
|
||||
"permissions": "AuthInterface",
|
||||
"knowledgeSharePermission": "Knowledge Share",
|
||||
"addApiKeyNotice": "This operation will generate an API key. Please confirm whether to proceed"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
"save": "保存配置"
|
||||
},
|
||||
"engineHint": "关键词检索引擎由平台全局配置决定,知识库侧不再单独配置。",
|
||||
"retrievalModeTitle": "召回方式说明",
|
||||
"retrievalModeDescriptions": {
|
||||
"hybrid": "同时结合向量语义与关键词匹配,适合默认场景,结果更均衡。",
|
||||
"vector": "优先按语义相似度召回,适合问法与原文表达差异较大的查询。",
|
||||
"keyword": "优先按字面词项匹配,适合术语、编号、专有名词等精确查找。"
|
||||
},
|
||||
"message": {
|
||||
"saveSuccess": "配置保存成功",
|
||||
"saveFailed": "配置保存失败"
|
||||
|
||||
@@ -14,5 +14,6 @@
|
||||
"failure": "已失效"
|
||||
},
|
||||
"permissions": "授权接口",
|
||||
"knowledgeSharePermission": "知识库分享授权",
|
||||
"addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ interface NetworkConnectionLike {
|
||||
|
||||
const CHUNK_ERROR_RELOAD_KEY = '__easyflow_chunk_error_reload_path__';
|
||||
|
||||
function isKnowledgeShareRoute(path: string) {
|
||||
return path === '/share/knowledge' || path === '/share/knowledge/expired';
|
||||
}
|
||||
|
||||
function isSlowNetworkConnection() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
@@ -238,6 +242,10 @@ function setupAccessGuard(router: Router) {
|
||||
return buildForcePasswordRoute();
|
||||
}
|
||||
|
||||
if (isKnowledgeShareRoute(to.path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (accessStore.isAccessChecked) {
|
||||
return true;
|
||||
|
||||
32
easyflow-ui-admin/app/src/router/routes/external/share.ts
vendored
Normal file
32
easyflow-ui-admin/app/src/router/routes/external/share.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
name: 'KnowledgeShare',
|
||||
path: '/share/knowledge',
|
||||
component: () =>
|
||||
import('#/views/ai/documentCollection/KnowledgeShareView.vue'),
|
||||
meta: {
|
||||
title: 'Knowledge Share',
|
||||
noBasicLayout: true,
|
||||
hideInMenu: true,
|
||||
hideInBreadcrumb: true,
|
||||
hideInTab: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'KnowledgeShareExpired',
|
||||
path: '/share/knowledge/expired',
|
||||
component: () =>
|
||||
import('#/views/ai/documentCollection/KnowledgeShareExpired.vue'),
|
||||
meta: {
|
||||
title: 'Knowledge Share Expired',
|
||||
noBasicLayout: true,
|
||||
hideInMenu: true,
|
||||
hideInBreadcrumb: true,
|
||||
hideInTab: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -9,17 +9,18 @@ const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
|
||||
});
|
||||
|
||||
// 有需要可以自行打开注释,并创建文件夹
|
||||
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
|
||||
const externalRouteFiles = import.meta.glob('./external/**/*.ts', {
|
||||
eager: true,
|
||||
});
|
||||
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
|
||||
|
||||
/** 动态路由 */
|
||||
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
|
||||
|
||||
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
|
||||
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
||||
const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
||||
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
|
||||
const staticRoutes: RouteRecordRaw[] = [];
|
||||
const externalRoutes: RouteRecordRaw[] = [];
|
||||
|
||||
/** 路由列表,由基本路由、外部路由和404兜底路由组成
|
||||
* 无需走权限验证(会一直显示在菜单中) */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
const props = defineProps({
|
||||
documentId: {
|
||||
@@ -31,6 +32,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const dialogVisible = ref(false);
|
||||
const pageDataRef = ref();
|
||||
@@ -54,8 +63,14 @@ const handleDelete = (row: any) => {
|
||||
})
|
||||
.then(() => {
|
||||
btnLoading.value = true;
|
||||
api
|
||||
.post('/api/v1/documentChunk/removeChunk', { id: row.id })
|
||||
props.requestClient
|
||||
.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/documentChunk/removeChunk',
|
||||
),
|
||||
{ id: row.id },
|
||||
)
|
||||
.then((res: any) => {
|
||||
btnLoading.value = false;
|
||||
if (res.errorCode !== 0) {
|
||||
@@ -85,16 +100,21 @@ const save = () => {
|
||||
return;
|
||||
}
|
||||
btnLoading.value = true;
|
||||
api.post('/api/v1/documentChunk/update', form.value).then((res: any) => {
|
||||
btnLoading.value = false;
|
||||
if (res.errorCode !== 0) {
|
||||
ElMessage.error(res.message);
|
||||
return;
|
||||
}
|
||||
ElMessage.success($t('message.updateOkMessage'));
|
||||
pageDataRef.value.setQuery(queryParams);
|
||||
closeDialog();
|
||||
});
|
||||
props.requestClient
|
||||
.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/documentChunk/update'),
|
||||
form.value,
|
||||
)
|
||||
.then((res: any) => {
|
||||
btnLoading.value = false;
|
||||
if (res.errorCode !== 0) {
|
||||
ElMessage.error(res.message);
|
||||
return;
|
||||
}
|
||||
ElMessage.success($t('message.updateOkMessage'));
|
||||
pageDataRef.value.setQuery(queryParams);
|
||||
closeDialog();
|
||||
});
|
||||
};
|
||||
const btnLoading = ref(false);
|
||||
const basicFormRef = ref();
|
||||
@@ -107,9 +127,12 @@ const form = ref({
|
||||
<template>
|
||||
<div>
|
||||
<PageData
|
||||
page-url="/api/v1/documentChunk/page"
|
||||
:page-url="
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/documentChunk/page')
|
||||
"
|
||||
ref="pageDataRef"
|
||||
:page-size="10"
|
||||
:request-client="props.requestClient"
|
||||
:extra-query-params="queryParams"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
|
||||
@@ -19,6 +19,7 @@ import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||
import KnowledgeSearchConfig from '#/views/ai/documentCollection/KnowledgeSearchConfig.vue';
|
||||
import KnowledgeShareManagement from '#/views/ai/documentCollection/KnowledgeShareManagement.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -73,8 +74,13 @@ const syncNavTitle = (title: string) => {
|
||||
};
|
||||
|
||||
const resolveDefaultMenu = (collectionType: string, menuKey: string) => {
|
||||
const faqMenus = new Set(['config', 'faqList', 'knowledgeSearch']);
|
||||
const documentMenus = new Set(['config', 'documentList', 'knowledgeSearch']);
|
||||
const faqMenus = new Set(['config', 'faqList', 'knowledgeSearch', 'share']);
|
||||
const documentMenus = new Set([
|
||||
'config',
|
||||
'documentList',
|
||||
'knowledgeSearch',
|
||||
'share',
|
||||
]);
|
||||
const fallbackMenu = collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
||||
|
||||
if (!menuKey) {
|
||||
@@ -119,6 +125,7 @@ const categoryData = computed(() => {
|
||||
name: $t('documentCollection.knowledgeRetrieval'),
|
||||
},
|
||||
{ key: 'config', name: $t('documentCollection.config') },
|
||||
{ key: 'share', name: '分享' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
@@ -128,6 +135,7 @@ const categoryData = computed(() => {
|
||||
name: $t('documentCollection.knowledgeRetrieval'),
|
||||
},
|
||||
{ key: 'config', name: $t('documentCollection.config') },
|
||||
{ key: 'share', name: '分享' },
|
||||
];
|
||||
});
|
||||
const headerButtons = [
|
||||
@@ -257,6 +265,13 @@ const backDoc = () => {
|
||||
@reload="getKnowledge"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="selectedCategory === 'share'">
|
||||
<KnowledgeShareManagement
|
||||
:knowledge-id="String(knowledgeId)"
|
||||
:collection-type="knowledgeInfo.collectionType || 'DOCUMENT'"
|
||||
:manageable="canManageCurrentKnowledge"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { api } from '#/api/request';
|
||||
import documentIcon from '#/assets/ai/knowledge/document.svg';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
const props = defineProps({
|
||||
knowledgeId: {
|
||||
@@ -30,6 +31,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['viewDoc']);
|
||||
defineExpose({
|
||||
@@ -44,8 +53,11 @@ const handleView = (row: any) => {
|
||||
emits('viewDoc', row.id);
|
||||
};
|
||||
const handleDownload = async (row: any) => {
|
||||
const blob = await api.download(
|
||||
`/api/v1/document/download?documentId=${row.id}`,
|
||||
const blob = await props.requestClient.download(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
`/api/v1/document/download?documentId=${row.id}`,
|
||||
),
|
||||
);
|
||||
downloadFileFromBlob({
|
||||
fileName: row.title || 'document',
|
||||
@@ -62,12 +74,17 @@ const handleDelete = (row: any) => {
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
api.post('/api/v1/document/removeDoc', { id: row.id }).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
pageDataRef.value.setQuery({ id: props.knowledgeId });
|
||||
}
|
||||
});
|
||||
props.requestClient
|
||||
.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
||||
{ id: row.id },
|
||||
)
|
||||
.then((res: any) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
pageDataRef.value.setQuery({ id: props.knowledgeId });
|
||||
}
|
||||
});
|
||||
// 删除逻辑
|
||||
});
|
||||
};
|
||||
@@ -75,9 +92,12 @@ const handleDelete = (row: any) => {
|
||||
|
||||
<template>
|
||||
<PageData
|
||||
page-url="/api/v1/document/documentList"
|
||||
:page-url="
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/documentList')
|
||||
"
|
||||
ref="pageDataRef"
|
||||
:page-size="10"
|
||||
:request-client="props.requestClient"
|
||||
:extra-query-params="{
|
||||
id: props.knowledgeId,
|
||||
sort: 'desc',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
|
||||
@@ -33,6 +34,14 @@ const props = defineProps({
|
||||
type: Array as any,
|
||||
default: () => [],
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'update:modelValue']);
|
||||
@@ -97,8 +106,11 @@ const editorConfig: any = {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.upload(
|
||||
'/api/v1/faqItem/uploadImage',
|
||||
const res = await props.requestClient.upload(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/faqItem/uploadImage',
|
||||
),
|
||||
{ collectionId: form.value.collectionId, file },
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -36,6 +37,14 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'success']);
|
||||
@@ -92,8 +101,11 @@ const downloadTemplate = async () => {
|
||||
}
|
||||
downloadLoading.value = true;
|
||||
try {
|
||||
const blob = await api.download(
|
||||
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
||||
const blob = await props.requestClient.download(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
||||
),
|
||||
);
|
||||
downloadFileFromBlob({
|
||||
fileName: 'faq_import_template.xlsx',
|
||||
@@ -114,9 +126,13 @@ const handleImport = async () => {
|
||||
formData.append('collectionId', props.knowledgeId);
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
const res = await api.postFile('/api/v1/faqItem/importExcel', formData, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
});
|
||||
const res = await props.requestClient.postFile(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqItem/importExcel'),
|
||||
formData,
|
||||
{
|
||||
timeout: 10 * 60 * 1000,
|
||||
},
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
importResult.value = res.data;
|
||||
ElMessage.success($t('documentCollection.faq.import.importFinished'));
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
import FaqCategoryDialog from './FaqCategoryDialog.vue';
|
||||
import FaqEditDialog from './FaqEditDialog.vue';
|
||||
@@ -47,6 +48,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const pageDataRef = ref();
|
||||
@@ -91,12 +100,15 @@ const refreshList = () => {
|
||||
};
|
||||
|
||||
const reloadCategoryTree = async () => {
|
||||
const res = await api.get('/api/v1/faqCategory/list', {
|
||||
params: {
|
||||
collectionId: props.knowledgeId,
|
||||
asTree: true,
|
||||
const res = await props.requestClient.get(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqCategory/list'),
|
||||
{
|
||||
params: {
|
||||
collectionId: props.knowledgeId,
|
||||
asTree: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
categoryTree.value = normalizeCategoryTree(res.data || []);
|
||||
|
||||
@@ -167,8 +179,11 @@ const downloadImportTemplate = async () => {
|
||||
}
|
||||
templateDownloadLoading.value = true;
|
||||
try {
|
||||
const blob = await api.download(
|
||||
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
||||
const blob = await props.requestClient.download(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
||||
),
|
||||
);
|
||||
downloadFileFromBlob({
|
||||
fileName: 'faq_import_template.xlsx',
|
||||
@@ -185,8 +200,11 @@ const exportFaqExcel = async () => {
|
||||
}
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
const blob = await api.download(
|
||||
`/api/v1/faqItem/exportExcel?collectionId=${props.knowledgeId}`,
|
||||
const blob = await props.requestClient.download(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
`/api/v1/faqItem/exportExcel?collectionId=${props.knowledgeId}`,
|
||||
),
|
||||
);
|
||||
downloadFileFromBlob({
|
||||
fileName: 'faq_export.xlsx',
|
||||
@@ -245,7 +263,10 @@ const saveFaq = async (payload: any) => {
|
||||
return;
|
||||
}
|
||||
const url = payload.id ? '/api/v1/faqItem/update' : '/api/v1/faqItem/save';
|
||||
const res = await api.post(url, payload);
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(props.endpointPrefix, url),
|
||||
payload,
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(
|
||||
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
||||
@@ -267,14 +288,21 @@ const removeFaq = (row: any) => {
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
api.post('/api/v1/faqItem/remove', { id: row.id }).then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
refreshList();
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
});
|
||||
props.requestClient
|
||||
.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqItem/remove'),
|
||||
{
|
||||
id: row.id,
|
||||
},
|
||||
)
|
||||
.then((res: any) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
refreshList();
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -380,7 +408,10 @@ const removeCategory = (node: any) => {
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
const res = await api.post('/api/v1/faqCategory/remove', { id: node.id });
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqCategory/remove'),
|
||||
{ id: node.id },
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
if (selectedCategoryId.value === String(node.id)) {
|
||||
@@ -467,7 +498,10 @@ const getChildrenByParentId = (parentId: string): any[] => {
|
||||
};
|
||||
|
||||
const updateCategoryRequest = async (payload: Record<string, any>) => {
|
||||
const res = await api.post('/api/v1/faqCategory/update', payload);
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqCategory/update'),
|
||||
payload,
|
||||
);
|
||||
if (res.errorCode !== 0) {
|
||||
throw new Error(res.message || $t('message.getDataError'));
|
||||
}
|
||||
@@ -664,7 +698,10 @@ const saveCategory = async (payload: any) => {
|
||||
const url = payload.id
|
||||
? '/api/v1/faqCategory/update'
|
||||
: '/api/v1/faqCategory/save';
|
||||
const res = await api.post(url, payload);
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(props.endpointPrefix, url),
|
||||
payload,
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(
|
||||
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
||||
@@ -909,8 +946,11 @@ onMounted(() => {
|
||||
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/faqItem/page"
|
||||
:page-url="
|
||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqItem/page')
|
||||
"
|
||||
:page-size="10"
|
||||
:request-client="props.requestClient"
|
||||
:extra-query-params="baseQueryParams"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
@@ -967,6 +1007,8 @@ onMounted(() => {
|
||||
v-model="dialogVisible"
|
||||
:data="editData"
|
||||
:category-options="categoryTreeOptions"
|
||||
:request-client="props.requestClient"
|
||||
:endpoint-prefix="props.endpointPrefix"
|
||||
@submit="saveFaq"
|
||||
/>
|
||||
|
||||
@@ -982,6 +1024,8 @@ onMounted(() => {
|
||||
<FaqImportDialog
|
||||
v-model="importDialogVisible"
|
||||
:knowledge-id="knowledgeId"
|
||||
:request-client="props.requestClient"
|
||||
:endpoint-prefix="props.endpointPrefix"
|
||||
@success="handleImportSuccess"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
@@ -11,6 +11,7 @@ import { api } from '#/api/request';
|
||||
import ComfirmImportDocument from '#/views/ai/documentCollection/ComfirmImportDocument.vue';
|
||||
import ImportKnowledgeFileContainer from '#/views/ai/documentCollection/ImportKnowledgeFileContainer.vue';
|
||||
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
|
||||
|
||||
interface UploadFileItem {
|
||||
@@ -30,9 +31,26 @@ interface PreviewItem {
|
||||
totalChunks?: number;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
knowledgeIdProp: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['importBack']);
|
||||
const route = useRoute();
|
||||
const knowledgeId = computed(() => (route.query.id as string) || '');
|
||||
const knowledgeId = computed(
|
||||
() => props.knowledgeIdProp || (route.query.id as string) || '',
|
||||
);
|
||||
|
||||
const fileUploadRef = ref<InstanceType<typeof ImportKnowledgeFileContainer>>();
|
||||
const segmenterDocRef = ref<InstanceType<typeof SegmenterDoc>>();
|
||||
@@ -46,6 +64,7 @@ const commitResults = ref<any[]>([]);
|
||||
const analyzing = ref(false);
|
||||
const previewing = ref(false);
|
||||
const committing = ref(false);
|
||||
let autoBackTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
const canGoPrevious = computed(() => activeStep.value > 0 && !committing.value);
|
||||
|
||||
@@ -53,6 +72,15 @@ function back() {
|
||||
emits('importBack');
|
||||
}
|
||||
|
||||
function scheduleBackAfterSuccess() {
|
||||
if (autoBackTimer) {
|
||||
clearTimeout(autoBackTimer);
|
||||
}
|
||||
autoBackTimer = setTimeout(() => {
|
||||
back();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function getUploadedFiles() {
|
||||
return fileUploadRef.value?.getFilesData?.() || [];
|
||||
}
|
||||
@@ -65,14 +93,18 @@ async function goToNextStep() {
|
||||
return;
|
||||
}
|
||||
files.value = currentFiles;
|
||||
await runAnalyze();
|
||||
activeStep.value = 1;
|
||||
const analyzed = await runAnalyze();
|
||||
if (analyzed) {
|
||||
activeStep.value = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeStep.value === 1) {
|
||||
await runPreview();
|
||||
activeStep.value = 2;
|
||||
const previewed = await runPreview();
|
||||
if (previewed) {
|
||||
activeStep.value = 2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,14 +123,24 @@ function goToPreviousStep() {
|
||||
async function runAnalyze() {
|
||||
analyzing.value = true;
|
||||
try {
|
||||
const res = await api.post('/api/v1/document/import/analyze', {
|
||||
const payload: Record<string, any> = {
|
||||
files: files.value.map((item) => ({
|
||||
fileName: item.fileName,
|
||||
filePath: item.filePath,
|
||||
})),
|
||||
knowledgeId: knowledgeId.value,
|
||||
});
|
||||
};
|
||||
if (knowledgeId.value) {
|
||||
payload.knowledgeId = knowledgeId.value;
|
||||
}
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/analyze',
|
||||
),
|
||||
payload,
|
||||
);
|
||||
analysisItems.value = res.data?.items || [];
|
||||
return true;
|
||||
} finally {
|
||||
analyzing.value = false;
|
||||
}
|
||||
@@ -109,16 +151,26 @@ async function runPreview() {
|
||||
segmenterDocRef.value?.getPreviewRequestItems?.() || [];
|
||||
if (previewRequestItems.length === 0) {
|
||||
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
previewing.value = true;
|
||||
try {
|
||||
const res = await api.post('/api/v1/document/import/preview', {
|
||||
const payload: Record<string, any> = {
|
||||
files: previewRequestItems,
|
||||
knowledgeId: knowledgeId.value,
|
||||
});
|
||||
};
|
||||
if (knowledgeId.value) {
|
||||
payload.knowledgeId = knowledgeId.value;
|
||||
}
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/preview',
|
||||
),
|
||||
payload,
|
||||
);
|
||||
previewItems.value = res.data?.items || [];
|
||||
commitResults.value = [];
|
||||
return true;
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
}
|
||||
@@ -131,20 +183,36 @@ async function confirmImport() {
|
||||
}
|
||||
committing.value = true;
|
||||
try {
|
||||
const res = await api.post('/api/v1/document/import/commit', {
|
||||
knowledgeId: knowledgeId.value,
|
||||
const payload: Record<string, any> = {
|
||||
previewSessionIds: previewItems.value.map(
|
||||
(item) => item.previewSessionId,
|
||||
),
|
||||
});
|
||||
};
|
||||
if (knowledgeId.value) {
|
||||
payload.knowledgeId = knowledgeId.value;
|
||||
}
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/document/import/commit',
|
||||
),
|
||||
payload,
|
||||
);
|
||||
commitResults.value = res.data?.results || [];
|
||||
if ((res.data?.errorCount || 0) === 0) {
|
||||
ElMessage.success($t('documentCollection.splitterDoc.importSuccess'));
|
||||
scheduleBackAfterSuccess();
|
||||
}
|
||||
} finally {
|
||||
committing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (autoBackTimer) {
|
||||
clearTimeout(autoBackTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { ElButton, ElInput, ElMessage, ElOption, ElSelect } from 'element-plus';
|
||||
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||
|
||||
@@ -24,6 +25,14 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const searchDataList = ref<SearchResultItem[]>([]);
|
||||
@@ -31,11 +40,29 @@ const keyword = ref('');
|
||||
const retrievalMode = ref<RetrievalMode>('HYBRID');
|
||||
const previewSearchKnowledgeRef = ref();
|
||||
|
||||
const retrievalModeOptions = [
|
||||
{ label: $t('bot.retrievalModes.hybrid'), value: 'HYBRID' },
|
||||
{ label: $t('bot.retrievalModes.vector'), value: 'VECTOR' },
|
||||
{ label: $t('bot.retrievalModes.keyword'), value: 'KEYWORD' },
|
||||
];
|
||||
const retrievalModeDescriptions = computed(() => [
|
||||
{
|
||||
key: 'HYBRID' as RetrievalMode,
|
||||
label: $t('bot.retrievalModes.hybrid'),
|
||||
description: $t(
|
||||
'documentCollectionSearch.retrievalModeDescriptions.hybrid',
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'VECTOR' as RetrievalMode,
|
||||
label: $t('bot.retrievalModes.vector'),
|
||||
description: $t(
|
||||
'documentCollectionSearch.retrievalModeDescriptions.vector',
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'KEYWORD' as RetrievalMode,
|
||||
label: $t('bot.retrievalModes.keyword'),
|
||||
description: $t(
|
||||
'documentCollectionSearch.retrievalModeDescriptions.keyword',
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!keyword.value) {
|
||||
@@ -43,14 +70,20 @@ const handleSearch = () => {
|
||||
return;
|
||||
}
|
||||
previewSearchKnowledgeRef.value.loadingContent(true);
|
||||
api
|
||||
.get('/api/v1/documentCollection/search', {
|
||||
params: {
|
||||
knowledgeId: props.knowledgeId,
|
||||
keyword: keyword.value,
|
||||
retrievalMode: retrievalMode.value,
|
||||
props.requestClient
|
||||
.get(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/documentCollection/search',
|
||||
),
|
||||
{
|
||||
params: {
|
||||
knowledgeId: props.knowledgeId,
|
||||
keyword: keyword.value,
|
||||
retrievalMode: retrievalMode.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
.then((res) => {
|
||||
searchDataList.value = res.data;
|
||||
})
|
||||
@@ -62,6 +95,10 @@ const handleSearch = () => {
|
||||
previewSearchKnowledgeRef.value.loadingContent(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
||||
retrievalMode.value = mode;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,23 +109,35 @@ const handleSearch = () => {
|
||||
:placeholder="$t('common.searchPlaceholder')"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<ElSelect v-model="retrievalMode" class="retrieval-select">
|
||||
<ElOption
|
||||
v-for="item in retrievalModeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton type="primary" @click="handleSearch">
|
||||
{{ $t('button.query') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="search-hint">
|
||||
<div class="search-hint__title">
|
||||
{{ $t('documentCollectionSearch.retrievalModeTitle') }}
|
||||
</div>
|
||||
<div class="search-hint__list">
|
||||
<button
|
||||
v-for="item in retrievalModeDescriptions"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="search-hint__item"
|
||||
:class="{ 'is-active': retrievalMode === item.key }"
|
||||
@click="handleRetrievalModeChange(item.key)"
|
||||
>
|
||||
<div class="search-hint__label">{{ item.label }}</div>
|
||||
<div class="search-hint__desc">{{ item.description }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-result">
|
||||
<PreviewSearchKnowledge
|
||||
ref="previewSearchKnowledgeRef"
|
||||
:data="searchDataList"
|
||||
:retrieval-mode="retrievalMode"
|
||||
ref="previewSearchKnowledgeRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,7 +148,6 @@ const handleSearch = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 0 20px;
|
||||
}
|
||||
|
||||
@@ -107,16 +155,84 @@ const handleSearch = () => {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.retrieval-select {
|
||||
flex-shrink: 0;
|
||||
width: 180px;
|
||||
.search-input :deep(.el-input) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-hint {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 18px 20px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.search-hint__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.search-hint__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-hint__item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.search-hint__item.is-active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
box-shadow: 0 6px 18px rgb(64 158 255 / 10%);
|
||||
}
|
||||
|
||||
.search-hint__item:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary-light-5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.search-hint__label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.search-hint__desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.search-result {
|
||||
flex: 1;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.search-hint__list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||
|
||||
const props = defineProps({
|
||||
knowledgeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
detailData: {
|
||||
type: Object as any,
|
||||
default: () => ({}),
|
||||
},
|
||||
requestClient: {
|
||||
type: Object as any,
|
||||
default: () => api,
|
||||
},
|
||||
endpointPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
const form = ref<any>({
|
||||
knowledgeId: '',
|
||||
vectorEmbedModelId: '',
|
||||
rerankModelId: '',
|
||||
rerankEnable: false,
|
||||
docRecallMaxNum: 5,
|
||||
simThreshold: 0.6,
|
||||
rebuildVectors: false,
|
||||
});
|
||||
|
||||
const embeddingModels = ref<any[]>([]);
|
||||
const rerankModels = ref<any[]>([]);
|
||||
const saving = ref(false);
|
||||
|
||||
const syncForm = () => {
|
||||
const options = props.detailData?.options || {};
|
||||
form.value = {
|
||||
knowledgeId: props.knowledgeId,
|
||||
vectorEmbedModelId: props.detailData?.vectorEmbedModelId || '',
|
||||
rerankModelId: props.detailData?.rerankModelId || '',
|
||||
rerankEnable:
|
||||
options?.rerankEnable === undefined
|
||||
? !!props.detailData?.rerankModelId
|
||||
: !!options.rerankEnable,
|
||||
docRecallMaxNum: Number(options?.docRecallMaxNum || 5),
|
||||
simThreshold: Number(options?.simThreshold || 0.6),
|
||||
rebuildVectors: false,
|
||||
};
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.detailData, props.knowledgeId],
|
||||
() => {
|
||||
syncForm();
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
const loadModels = async () => {
|
||||
const [embeddingRes, rerankRes] = await Promise.all([
|
||||
props.requestClient.get(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/documentCollection/modelList',
|
||||
),
|
||||
{ params: { modelType: 'embeddingModel' } },
|
||||
),
|
||||
props.requestClient.get(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/documentCollection/modelList',
|
||||
),
|
||||
{ params: { modelType: 'rerankModel' } },
|
||||
),
|
||||
]);
|
||||
embeddingModels.value = embeddingRes?.data || [];
|
||||
rerankModels.value = rerankRes?.data || [];
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await props.requestClient.post(
|
||||
buildKnowledgePath(
|
||||
props.endpointPrefix,
|
||||
'/api/v1/documentCollection/shareConfigUpdate',
|
||||
),
|
||||
form.value,
|
||||
);
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.saveOkMessage'));
|
||||
emit('reload');
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadModels();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard shadow="never" class="share-config-card">
|
||||
<template #header>
|
||||
<div class="share-config-card__header">
|
||||
<div>
|
||||
<div class="share-config-card__title">受限配置</div>
|
||||
<div class="share-config-card__desc">
|
||||
仅开放模型切换、检索参数与向量重建
|
||||
</div>
|
||||
</div>
|
||||
<ElButton type="primary" :loading="saving" @click="handleSave">
|
||||
{{ $t('button.save') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm label-position="top" class="share-config-form">
|
||||
<ElFormItem :label="$t('documentCollection.vectorEmbedLlmId')">
|
||||
<ElSelect v-model="form.vectorEmbedModelId">
|
||||
<ElOption
|
||||
v-for="item in embeddingModels"
|
||||
:key="item.id"
|
||||
:label="item.title"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('documentCollection.rerankLlmId')">
|
||||
<ElSelect v-model="form.rerankModelId" clearable>
|
||||
<ElOption
|
||||
v-for="item in rerankModels"
|
||||
:key="item.id"
|
||||
:label="item.title"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('documentCollection.rerankEnable')">
|
||||
<ElSwitch v-model="form.rerankEnable" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('documentCollectionSearch.docRecallMaxNum.label')">
|
||||
<ElInputNumber v-model="form.docRecallMaxNum" :min="1" :max="50" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem :label="$t('documentCollectionSearch.simThreshold.label')">
|
||||
<ElInputNumber
|
||||
v-model="form.simThreshold"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="保存后重建向量">
|
||||
<ElSwitch v-model="form.rebuildVectors" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.share-config-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.share-config-card__header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.share-config-card__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share-config-card__desc {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.share-config-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.share-config-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.share-config-card__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const isInvalid = computed(() => route.query.reason === 'invalid');
|
||||
const title = computed(() => (isInvalid.value ? '链接无效' : '链接已过期'));
|
||||
const description = computed(() =>
|
||||
isInvalid.value
|
||||
? '请确认链接完整无误,或联系分享人重新生成。'
|
||||
: '请联系分享人重新生成可用链接。',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="share-expired">
|
||||
<div class="share-expired__card">
|
||||
<div class="share-expired__title">{{ title }}</div>
|
||||
<div class="share-expired__desc">{{ description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.share-expired {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgb(255 240 240 / 88%),
|
||||
transparent 28%
|
||||
),
|
||||
linear-gradient(180deg, #faf7f6 0%, #f3efed 100%);
|
||||
}
|
||||
|
||||
.share-expired__card {
|
||||
width: min(460px, 100%);
|
||||
padding: 36px 28px;
|
||||
text-align: center;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
border: 1px solid rgb(220 227 235 / 72%);
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.share-expired__title {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.share-expired__desc {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,531 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
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';
|
||||
|
||||
type EndpointParam = {
|
||||
location: 'body' | 'query';
|
||||
name: string;
|
||||
note?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type EndpointDoc = {
|
||||
hint: string;
|
||||
method: 'GET' | 'POST';
|
||||
params: EndpointParam[];
|
||||
path: string;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
knowledgeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
collectionType: {
|
||||
type: String,
|
||||
default: 'DOCUMENT',
|
||||
},
|
||||
manageable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
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 detailExample = computed(() => {
|
||||
return [
|
||||
`curl -X GET '${apiBaseUrl.value}/detail?knowledgeId=${props.knowledgeId}' \\`,
|
||||
" -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 commonEndpoints: EndpointDoc[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/detail',
|
||||
hint: '获取知识库详情',
|
||||
params: [{ name: 'knowledgeId', location: 'query', required: true }],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/search',
|
||||
hint: '知识检索',
|
||||
params: [
|
||||
{ name: 'knowledgeId', location: 'query', required: true },
|
||||
{ name: 'keyword', location: 'query', required: true },
|
||||
{
|
||||
name: 'retrievalMode',
|
||||
location: 'query',
|
||||
note: '召回方式',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const typeEndpoints: EndpointDoc[] =
|
||||
props.collectionType === 'FAQ'
|
||||
? [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/faq/page',
|
||||
hint: 'FAQ 分页',
|
||||
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: 'POST',
|
||||
path: '/faq/save',
|
||||
hint: '新增 FAQ',
|
||||
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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/document/page',
|
||||
hint: '文档分页',
|
||||
params: [
|
||||
{ name: 'knowledgeId', location: 'query', required: true },
|
||||
{ name: 'title', location: 'query', note: '文档标题' },
|
||||
{ name: 'pageNumber', location: 'query', note: '默认 1' },
|
||||
{ name: 'pageSize', location: 'query', note: '默认 10' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/document/import/commit',
|
||||
hint: '导入文档',
|
||||
params: [
|
||||
{ name: 'knowledgeId', location: 'body', required: true },
|
||||
{
|
||||
name: 'previewSessionIds',
|
||||
location: 'body',
|
||||
required: true,
|
||||
note: '预览接口返回的会话 ID 数组',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return [...commonEndpoints, ...typeEndpoints].map((item) => ({
|
||||
...item,
|
||||
url: `${apiBaseUrl.value}${item.path}`,
|
||||
}));
|
||||
});
|
||||
|
||||
const formatEndpointParam = (param: EndpointParam) => {
|
||||
const segments = [param.location, param.required ? '必填' : '可选'];
|
||||
if (param.note) {
|
||||
segments.push(param.note);
|
||||
}
|
||||
return `${param.name}(${segments.join(',')})`;
|
||||
};
|
||||
|
||||
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) {
|
||||
generatedUrl.value = res.data?.shareUrl || '';
|
||||
generatedExpireAt.value = res.data?.expiresAt || '';
|
||||
ElMessage.success('已创建分享链接');
|
||||
}
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyGeneratedUrl = async () => {
|
||||
if (!generatedUrl.value) {
|
||||
ElMessage.warning('请先生成分享链接');
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(generatedUrl.value);
|
||||
ElMessage.success('已复制分享链接');
|
||||
};
|
||||
|
||||
const copyApiExample = async (content: string) => {
|
||||
await navigator.clipboard.writeText(content);
|
||||
ElMessage.success('已复制调用示例');
|
||||
};
|
||||
|
||||
const copyEndpointUrl = async (url: string) => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
ElMessage.success('已复制接口地址');
|
||||
};
|
||||
</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">
|
||||
使用已开通知识库分享授权的访问令牌,通过 `ApiKey` 请求头调用。
|
||||
</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">当前知识库 ID</span>
|
||||
<code class="api-doc__value">{{ props.knowledgeId }}</code>
|
||||
</div>
|
||||
</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>
|
||||
<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__endpoint-params">
|
||||
<span class="api-doc__params-label">参数</span>
|
||||
<span
|
||||
v-for="param in endpoint.params"
|
||||
:key="`${endpoint.path}-${param.name}`"
|
||||
class="api-doc__param"
|
||||
>
|
||||
{{ formatEndpointParam(param) }}
|
||||
</span>
|
||||
</div>
|
||||
</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;
|
||||
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__section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.api-doc__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.api-doc__endpoint-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-doc__endpoint {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
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__endpoint-params {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.api-doc__params-label {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.api-doc__param {
|
||||
padding: 2px 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.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-wrap;
|
||||
word-break: break-all;
|
||||
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>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { ElButton, ElCard, ElImage, ElTag } from 'element-plus';
|
||||
|
||||
import { knowledgeShareApi } from '#/api/knowledge-share';
|
||||
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
|
||||
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
||||
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||
import KnowledgeShareConfigPanel from '#/views/ai/documentCollection/KnowledgeShareConfigPanel.vue';
|
||||
|
||||
const endpointPrefix = '/api/v1/share/knowledge';
|
||||
const knowledgeInfo = ref<any>({});
|
||||
const loading = ref(false);
|
||||
const selectedCategory = ref('');
|
||||
const viewDocVisible = ref(false);
|
||||
const documentId = ref('');
|
||||
const importVisible = ref(false);
|
||||
const documentTableRef = ref();
|
||||
|
||||
const isFaqCollection = computed(
|
||||
() => knowledgeInfo.value.collectionType === 'FAQ',
|
||||
);
|
||||
const collectionTypeLabel = computed(() =>
|
||||
isFaqCollection.value
|
||||
? $t('documentCollection.collectionTypeFaq')
|
||||
: $t('documentCollection.collectionTypeDocument'),
|
||||
);
|
||||
const knowledgeTitle = computed(
|
||||
() =>
|
||||
knowledgeInfo.value.title ||
|
||||
knowledgeInfo.value.alias ||
|
||||
knowledgeInfo.value.englishName ||
|
||||
'',
|
||||
);
|
||||
const knowledgeDescription = computed(
|
||||
() => knowledgeInfo.value.description || '',
|
||||
);
|
||||
const categoryData = computed(() => {
|
||||
if (isFaqCollection.value) {
|
||||
return [
|
||||
{ key: 'faqList', name: $t('documentCollection.faq.faqList') },
|
||||
{
|
||||
key: 'knowledgeSearch',
|
||||
name: $t('documentCollection.knowledgeRetrieval'),
|
||||
},
|
||||
{ key: 'config', name: $t('documentCollection.config') },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ key: 'documentList', name: $t('documentCollection.documentList') },
|
||||
{
|
||||
key: 'knowledgeSearch',
|
||||
name: $t('documentCollection.knowledgeRetrieval'),
|
||||
},
|
||||
{ key: 'config', name: $t('documentCollection.config') },
|
||||
];
|
||||
});
|
||||
|
||||
const loadKnowledge = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await knowledgeShareApi.get(
|
||||
`${endpointPrefix}/documentCollection/detail`,
|
||||
);
|
||||
knowledgeInfo.value = res.data || {};
|
||||
selectedCategory.value =
|
||||
knowledgeInfo.value.collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDoc = (id: string) => {
|
||||
documentId.value = id;
|
||||
viewDocVisible.value = true;
|
||||
};
|
||||
|
||||
const backToDocumentList = () => {
|
||||
viewDocVisible.value = false;
|
||||
};
|
||||
|
||||
const openImport = () => {
|
||||
importVisible.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadKnowledge();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="share-page" v-loading="loading">
|
||||
<div v-if="!importVisible" class="share-shell">
|
||||
<ElCard shadow="never" class="share-hero">
|
||||
<div class="share-hero__main">
|
||||
<div class="share-hero__meta">
|
||||
<ElImage :src="bookIcon" class="share-hero__icon" />
|
||||
<div class="share-hero__text">
|
||||
<div class="share-hero__title-row">
|
||||
<div class="share-hero__title">{{ knowledgeTitle }}</div>
|
||||
<ElTag size="small" effect="plain" round>
|
||||
{{ collectionTypeLabel }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div v-if="knowledgeDescription" class="share-hero__desc">
|
||||
{{ knowledgeDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-hero__actions">
|
||||
<ElButton
|
||||
v-if="!isFaqCollection"
|
||||
type="primary"
|
||||
@click="openImport"
|
||||
>
|
||||
{{ $t('button.importFile') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-tabs">
|
||||
<button
|
||||
v-for="item in categoryData"
|
||||
:key="item.key"
|
||||
class="share-tab"
|
||||
:class="{ 'is-active': selectedCategory === item.key }"
|
||||
@click="selectedCategory = item.key"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<div class="share-body">
|
||||
<div v-if="selectedCategory === 'documentList'" class="share-panel">
|
||||
<DocumentTable
|
||||
v-if="!viewDocVisible"
|
||||
ref="documentTableRef"
|
||||
:knowledge-id="knowledgeInfo.id"
|
||||
:manageable="true"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
@view-doc="handleViewDoc"
|
||||
/>
|
||||
<ChunkDocumentTable
|
||||
v-else
|
||||
:document-id="documentId"
|
||||
:manageable="true"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
/>
|
||||
<div v-if="viewDocVisible" class="share-panel__footer">
|
||||
<ElButton @click="backToDocumentList">
|
||||
{{ $t('button.back') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCategory === 'faqList'" class="share-panel">
|
||||
<FaqTable
|
||||
:knowledge-id="knowledgeInfo.id"
|
||||
:manageable="true"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCategory === 'knowledgeSearch'" class="share-search">
|
||||
<KnowledgeSearch
|
||||
:knowledge-id="knowledgeInfo.id"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCategory === 'config'" class="share-config">
|
||||
<KnowledgeShareConfigPanel
|
||||
:knowledge-id="String(knowledgeInfo.id || '')"
|
||||
:detail-data="knowledgeInfo"
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
@reload="loadKnowledge"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportKnowledgeDocFile
|
||||
v-else
|
||||
:request-client="knowledgeShareApi"
|
||||
:endpoint-prefix="endpointPrefix"
|
||||
:knowledge-id-prop="String(knowledgeInfo.id || '')"
|
||||
@import-back="importVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.share-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px 20px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgb(232 242 255 / 90%),
|
||||
transparent 32%
|
||||
),
|
||||
linear-gradient(180deg, #f6f9fc 0%, #f2f5f9 100%);
|
||||
}
|
||||
|
||||
.share-shell {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.share-hero {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.share-hero__main {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.share-hero__meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.share-hero__text {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.share-hero__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.share-hero__title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.share-hero__title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.share-hero__desc {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.share-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.share-tab {
|
||||
padding: 10px 16px;
|
||||
color: var(--el-text-color-regular);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.share-tab.is-active {
|
||||
color: var(--el-color-primary);
|
||||
background: rgb(64 158 255 / 8%);
|
||||
border-color: rgb(64 158 255 / 18%);
|
||||
}
|
||||
|
||||
.share-body,
|
||||
.share-panel,
|
||||
.share-search,
|
||||
.share-config {
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.share-panel__footer {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.share-page {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.share-hero__main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.share-hero__meta {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.share-hero__eyebrow {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.share-tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.share-hero__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -74,6 +74,22 @@ const resolveHitSourceLabel = (hitSource?: HitSource) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveDisplayHitSource = (
|
||||
hitSource: HitSource | undefined,
|
||||
retrievalMode: RetrievalMode,
|
||||
) => {
|
||||
if (hitSource) {
|
||||
return hitSource;
|
||||
}
|
||||
if (retrievalMode === 'VECTOR') {
|
||||
return 'VECTOR' as HitSource;
|
||||
}
|
||||
if (retrievalMode === 'KEYWORD') {
|
||||
return 'KEYWORD' as HitSource;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolveHitSourceType = (hitSource?: HitSource) => {
|
||||
if (hitSource === 'VECTOR') {
|
||||
return 'success';
|
||||
@@ -130,15 +146,23 @@ defineExpose({
|
||||
</div>
|
||||
<div class="content-desc">{{ item.content }}</div>
|
||||
<div
|
||||
v-if="retrievalMode === 'HYBRID' && item.hitSource"
|
||||
v-if="resolveDisplayHitSource(item.hitSource, retrievalMode)"
|
||||
class="hit-source-row"
|
||||
>
|
||||
<ElTag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="resolveHitSourceType(item.hitSource)"
|
||||
:type="
|
||||
resolveHitSourceType(
|
||||
resolveDisplayHitSource(item.hitSource, retrievalMode),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ resolveHitSourceLabel(item.hitSource) }}
|
||||
{{
|
||||
resolveHitSourceLabel(
|
||||
resolveDisplayHitSource(item.hitSource, retrievalMode),
|
||||
)
|
||||
}}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,8 +196,6 @@ defineExpose({
|
||||
<style scoped>
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 8%);
|
||||
@@ -207,7 +229,6 @@ defineExpose({
|
||||
|
||||
.preview-content {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.preview-list {
|
||||
.segment-badge {
|
||||
@@ -257,6 +278,7 @@ defineExpose({
|
||||
|
||||
.hit-source-row {
|
||||
display: flex;
|
||||
align-self: flex-end;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function buildKnowledgePath(endpointPrefix = '', path = '') {
|
||||
if (!endpointPrefix) {
|
||||
return path;
|
||||
}
|
||||
const normalizedPrefix = endpointPrefix.endsWith('/')
|
||||
? endpointPrefix.slice(0, -1)
|
||||
: endpointPrefix;
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
if (normalizedPath.startsWith(`${normalizedPrefix}/`)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
if (normalizedPath.startsWith('/api/v1/')) {
|
||||
return `${normalizedPrefix}${normalizedPath.slice('/api/v1'.length)}`;
|
||||
}
|
||||
return `${normalizedPrefix}${normalizedPath}`;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { onMounted, ref } from 'vue';
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElDatePicker,
|
||||
@@ -33,6 +32,7 @@ interface Entity {
|
||||
deptId: number | string;
|
||||
expiredAt: Date | null | string;
|
||||
permissionIds: (number | string)[]; // 绑定值:权限 ID 数组
|
||||
knowledgeShareEnabled: boolean;
|
||||
id?: number; // 编辑时的主键
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ const entity = ref<Entity>({
|
||||
deptId: '',
|
||||
expiredAt: null,
|
||||
permissionIds: [],
|
||||
knowledgeShareEnabled: false,
|
||||
});
|
||||
// 加载状态
|
||||
const btnLoading = ref(false);
|
||||
@@ -74,18 +75,30 @@ const rules = ref({
|
||||
],
|
||||
});
|
||||
|
||||
function openDialog(row: Partial<Entity> = {}) {
|
||||
async function openDialog(row: Partial<Entity> = {}) {
|
||||
saveForm.value?.resetFields();
|
||||
entity.value = {
|
||||
apiKey: '',
|
||||
status: '',
|
||||
deptId: '',
|
||||
expiredAt: null,
|
||||
permissionIds: [],
|
||||
...row,
|
||||
};
|
||||
isAdd.value = !row.id;
|
||||
dialogVisible.value = true;
|
||||
if (!row.id) {
|
||||
entity.value = createDefaultEntity(row);
|
||||
dialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
btnLoading.value = true;
|
||||
try {
|
||||
const res = await api.get('/api/v1/sysApiKey/detail', {
|
||||
params: { id: row.id },
|
||||
});
|
||||
if (res.errorCode !== 0) {
|
||||
ElMessage.error(res.message || $t('message.getDataError'));
|
||||
return;
|
||||
}
|
||||
entity.value = createDefaultEntity(res.data || row);
|
||||
dialogVisible.value = true;
|
||||
} catch {
|
||||
ElMessage.error($t('message.getDataError'));
|
||||
} finally {
|
||||
btnLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取资源权限列表
|
||||
@@ -104,6 +117,33 @@ function getResourcePermissionList() {
|
||||
});
|
||||
}
|
||||
|
||||
function createDefaultEntity(row: Partial<Entity> = {}): Entity {
|
||||
const permissionIds = row.permissionIds || [];
|
||||
const knowledgeShareEnabled = Boolean(row.knowledgeShareEnabled);
|
||||
return {
|
||||
apiKey: '',
|
||||
status: '',
|
||||
deptId: '',
|
||||
expiredAt: null,
|
||||
...row,
|
||||
permissionIds,
|
||||
knowledgeShareEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPermissionLabel(item: ResourcePermission) {
|
||||
if (item.requestInterface === '/public-api/bot/chat') {
|
||||
return '聊天助手调用';
|
||||
}
|
||||
if (
|
||||
item.requestInterface === '/v1/chat/completions' ||
|
||||
item.requestInterface === '/public-api/openai/v1/chat/completions'
|
||||
) {
|
||||
return '统一模型调用';
|
||||
}
|
||||
return item.title;
|
||||
}
|
||||
|
||||
// 保存表单
|
||||
function save() {
|
||||
saveForm.value?.validate((valid) => {
|
||||
@@ -142,6 +182,7 @@ function closeDialog() {
|
||||
deptId: '',
|
||||
expiredAt: null,
|
||||
permissionIds: [],
|
||||
knowledgeShareEnabled: false,
|
||||
};
|
||||
isAdd.value = true;
|
||||
dialogVisible.value = false;
|
||||
@@ -200,23 +241,27 @@ defineExpose({
|
||||
:label="$t('sysApiKey.permissions')"
|
||||
class="permission-form-item"
|
||||
>
|
||||
<ElAlert type="info">
|
||||
接口信息请运行:tech.easyflow.publicapi.SyncApis 的 main
|
||||
方法同步到数据库。
|
||||
</ElAlert>
|
||||
<ElCheckboxGroup
|
||||
v-model="entity.permissionIds"
|
||||
class="permission-checkbox-group"
|
||||
>
|
||||
<div class="permission-section">
|
||||
<ElCheckboxGroup
|
||||
v-model="entity.permissionIds"
|
||||
class="permission-checkbox-group"
|
||||
>
|
||||
<ElCheckbox
|
||||
v-for="item in resourcePermissionList"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
class="permission-checkbox"
|
||||
>
|
||||
{{ renderPermissionLabel(item) }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
<ElCheckbox
|
||||
v-for="item in resourcePermissionList"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
v-model="entity.knowledgeShareEnabled"
|
||||
class="permission-checkbox"
|
||||
>
|
||||
{{ item.requestInterface }} - {{ item.title }}
|
||||
{{ $t('sysApiKey.knowledgeSharePermission') }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
@@ -230,15 +275,27 @@ defineExpose({
|
||||
}
|
||||
|
||||
.permission-form-item .el-form-item__content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.permission-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.permission-checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.permission-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin: 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container::-webkit-scrollbar {
|
||||
|
||||
@@ -26,6 +26,11 @@ export default defineConfig(async () => {
|
||||
target: 'http://127.0.0.1:8111',
|
||||
ws: true,
|
||||
},
|
||||
'/flow/public-api': {
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/flow/, ''),
|
||||
target: 'http://127.0.0.1:8111',
|
||||
},
|
||||
'/flow/userCenter': {
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/flow/, ''),
|
||||
|
||||
Reference in New Issue
Block a user