Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/documentCollection/KnowledgeShareManagement.vue
陈子默 31a755a8bc feat: 收口知识库分享链路
- 新增 shareKey 单参数 URL 分享页与失效页

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

- 在访问令牌中增加知识库分享授权入口
2026-04-13 14:44:31 +08:00

532 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue';
import { $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>