feat: 收口知识库分享链路

- 新增 shareKey 单参数 URL 分享页与失效页

- 新增知识库分享后端鉴权、审计与迁移脚本

- 在访问令牌中增加知识库分享授权入口
This commit is contained in:
2026-04-13 14:44:31 +08:00
parent 8cfe5400fe
commit 31a755a8bc
57 changed files with 5158 additions and 143 deletions

View 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);
}

View File

@@ -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"

View File

@@ -14,5 +14,6 @@
"failure": "Failure"
},
"permissions": "AuthInterface",
"knowledgeSharePermission": "Knowledge Share",
"addApiKeyNotice": "This operation will generate an API key. Please confirm whether to proceed"
}

View File

@@ -12,6 +12,12 @@
"save": "保存配置"
},
"engineHint": "关键词检索引擎由平台全局配置决定,知识库侧不再单独配置。",
"retrievalModeTitle": "召回方式说明",
"retrievalModeDescriptions": {
"hybrid": "同时结合向量语义与关键词匹配,适合默认场景,结果更均衡。",
"vector": "优先按语义相似度召回,适合问法与原文表达差异较大的查询。",
"keyword": "优先按字面词项匹配,适合术语、编号、专有名词等精确查找。"
},
"message": {
"saveSuccess": "配置保存成功",
"saveFailed": "配置保存失败"

View File

@@ -14,5 +14,6 @@
"failure": "已失效"
},
"permissions": "授权接口",
"knowledgeSharePermission": "知识库分享授权",
"addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成"
}

View File

@@ -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;

View 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;

View File

@@ -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兜底路由组成
* 无需走权限验证(会一直显示在菜单中) */

View File

@@ -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 }">

View File

@@ -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>

View File

@@ -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',

View File

@@ -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 },
{},
);

View 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'));

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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}`;
}

View File

@@ -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 {

View File

@@ -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/, ''),