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