feat: 收口知识库分享链路
- 新增 shareKey 单参数 URL 分享页与失效页 - 新增知识库分享后端鉴权、审计与迁移脚本 - 在访问令牌中增加知识库分享授权入口
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user