feat(knowledge): 知识源维护与 TXT/Markdown/PDF 导入
支持文本、Markdown 与非扫描 PDF 上传解析,展示提取文本与切块结果。
This commit is contained in:
1 parent
4ff934ad98
commit
7937de5d7d
23 files changed
+7545
No files matched your search
@@ -0,0 +1,597 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { AddSourcePayload, CrawlScope } from '@/composables/useKnowledgeExplorer'
|
||||
|
||||
type SourceKind = 'website' | 'sitemap' | 'pdf' | 'text'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
submitting?: boolean
|
||||
}>(),
|
||||
{ submitting: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'submit', payload: AddSourcePayload): void
|
||||
}>()
|
||||
|
||||
const kind = ref<SourceKind>('website')
|
||||
const url = ref('')
|
||||
const scope = ref<CrawlScope>('host')
|
||||
const files = ref<File[]>([])
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const dragOver = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const types: { key: SourceKind; title: string; sub: string }[] = [
|
||||
{ key: 'website', title: '网站', sub: '抓取单个页面或整个站点' },
|
||||
{ key: 'sitemap', title: '站点地图', sub: '索引其中列出的所有页面' },
|
||||
{ key: 'pdf', title: '文档', sub: 'PDF 文件,最大 25 MB' },
|
||||
{ key: 'text', title: '文本', sub: '直接粘贴内容' },
|
||||
]
|
||||
|
||||
const willQueue = computed(() => kind.value !== 'text')
|
||||
const submitLabel = computed(() => (kind.value === 'text' ? '添加页面' : '加入抓取队列'))
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.submitting) return false
|
||||
if (kind.value === 'website' || kind.value === 'sitemap') return url.value.trim().length > 0
|
||||
if (kind.value === 'pdf') return files.value.length > 0
|
||||
return title.value.trim().length > 0 && content.value.trim().length > 0
|
||||
})
|
||||
|
||||
function normalizeUrl(raw: string): string {
|
||||
const v = raw.trim()
|
||||
// Leave an explicit scheme alone; otherwise assume https.
|
||||
return v.includes('://') ? v : `https://${v}`
|
||||
}
|
||||
|
||||
// Parsed form of what's typed so far, used to name the host/section in the
|
||||
// scope options. Null while the URL is empty or not yet valid.
|
||||
const parsedUrl = computed<URL | null>(() => {
|
||||
if (!url.value.trim()) return null
|
||||
try {
|
||||
return new URL(normalizeUrl(url.value))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const seedHost = computed(() => parsedUrl.value?.hostname ?? '')
|
||||
// The section is the URL's own path. Empty for a homepage URL — there is no
|
||||
// section to speak of, and "pages under /" would just be "This site" again.
|
||||
const seedSection = computed(() => parsedUrl.value?.pathname.replace(/\/$/, '') ?? '')
|
||||
|
||||
const scopes = computed<{ key: CrawlScope; title: string; sub: string }[]>(() => {
|
||||
const host = seedHost.value
|
||||
const section = seedSection.value
|
||||
return [
|
||||
{ key: 'page', title: '仅此页面', sub: '仅索引上方这个 URL。' },
|
||||
...(section
|
||||
? [
|
||||
{
|
||||
key: 'path' as CrawlScope,
|
||||
title: '本栏目',
|
||||
sub: `仅 ${host} 上 ${section}/ 下的页面。`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'host',
|
||||
title: '整个站点',
|
||||
sub: host ? `${host} 上的所有页面。` : '同一主机上的所有页面。',
|
||||
},
|
||||
{
|
||||
key: 'domain',
|
||||
// No domain name in the copy on purpose: working out that
|
||||
// paywithatoa.co.uk is one registrable domain (not 'co.uk') needs the
|
||||
// ccTLD list the backend keeps, and a second copy of it would drift.
|
||||
title: '整个域名',
|
||||
sub: '也包括其他子域名,如 blog、docs、app。',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// Editing the URL down to a homepage removes the section option; don't leave a
|
||||
// scope selected that is no longer on screen.
|
||||
watch(scopes, (options) => {
|
||||
if (!options.some((option) => option.key === scope.value)) scope.value = 'host'
|
||||
})
|
||||
|
||||
function pickFiles() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const isPdf = (file: File) => file.type === 'application/pdf' || /\.pdf$/i.test(file.name)
|
||||
|
||||
function onFilesSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) files.value = Array.from(input.files).filter(isPdf)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragOver.value = false
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
// Only accept PDFs — the backend upload endpoint validates PDF magic bytes.
|
||||
const pdfs = Array.from(dropped).filter(isPdf)
|
||||
if (pdfs.length) files.value = pdfs
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
if (kind.value === 'website') {
|
||||
emit('submit', { type: 'website', url: normalizeUrl(url.value), scope: scope.value })
|
||||
} else if (kind.value === 'sitemap') {
|
||||
emit('submit', { type: 'sitemap', url: normalizeUrl(url.value) })
|
||||
} else if (kind.value === 'pdf') {
|
||||
emit('submit', { type: 'pdf', files: files.value })
|
||||
} else {
|
||||
emit('submit', { type: 'text', title: title.value.trim(), content: content.value })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" @click.self="emit('close')">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="添加知识来源">
|
||||
<div class="modal__head">
|
||||
<div>
|
||||
<h3 class="modal__title">添加知识来源</h3>
|
||||
<p class="modal__sub">选择来源类型,我们会在后台抓取并建立索引。</p>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="types">
|
||||
<button
|
||||
v-for="t in types"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
class="type"
|
||||
:class="{ 'type--active': kind === t.key }"
|
||||
@click="kind = t.key"
|
||||
>
|
||||
<span class="type__title">{{ t.title }}</span>
|
||||
<span class="type__sub">{{ t.sub }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<!-- website -->
|
||||
<template v-if="kind === 'website'">
|
||||
<label class="field-label" for="kb-add-url">页面 URL</label>
|
||||
<input id="kb-add-url" v-model="url" class="text-input" type="text"
|
||||
placeholder="https://docs.yourcompany.com/help" @keyup.enter="submit" />
|
||||
<label class="field-label">抓取范围</label>
|
||||
<div class="scope">
|
||||
<button v-for="opt in scopes" :key="opt.key" type="button" class="scope__opt"
|
||||
:class="{ 'scope__opt--active': scope === opt.key }" @click="scope = opt.key">
|
||||
<span class="radio" :class="{ 'radio--on': scope === opt.key }"></span>
|
||||
<span>
|
||||
<span class="scope__title">{{ opt.title }}</span>
|
||||
<span class="scope__sub">{{ opt.sub }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="scope !== 'page'" class="scope__hint">
|
||||
系统会发现并排队抓取关联页面,数量以套餐上限为准。
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- sitemap -->
|
||||
<template v-else-if="kind === 'sitemap'">
|
||||
<label class="field-label" for="kb-add-sitemap">站点地图 URL</label>
|
||||
<input id="kb-add-sitemap" v-model="url" class="text-input" type="text"
|
||||
placeholder="https://yourcompany.com/sitemap.xml" @keyup.enter="submit" />
|
||||
<div class="note note--teal">
|
||||
我们会读取站点地图,并将其中列出的每个页面加入抓取队列。较大的站点地图会分批处理。
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- document -->
|
||||
<template v-else-if="kind === 'pdf'">
|
||||
<div
|
||||
class="drop"
|
||||
:class="{ 'drop--over': dragOver }"
|
||||
@click="pickFiles"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input ref="fileInput" type="file" accept="application/pdf" multiple class="hidden-file"
|
||||
@change="onFilesSelected" />
|
||||
<span v-if="files.length" class="drop__name">{{ files.map((f) => f.name).join(', ') }}</span>
|
||||
<span v-else class="drop__name">拖入 PDF 或点击选择文件</span>
|
||||
<span class="drop__hint">PDF 文件 · 每个最大 25 MB</span>
|
||||
</div>
|
||||
<div class="note note--coral">
|
||||
我们会提取文本、切分成多个章节,并逐一建立索引。扫描版 PDF 会经过 OCR 处理。
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- text -->
|
||||
<template v-else>
|
||||
<label class="field-label" for="kb-add-title">标题</label>
|
||||
<input id="kb-add-title" v-model="title" class="text-input" type="text" placeholder="例如:退款政策" />
|
||||
<label class="field-label" for="kb-add-content">内容</label>
|
||||
<textarea id="kb-add-content" v-model="content" class="textarea"
|
||||
placeholder="粘贴或输入希望智能体学习的内容…"></textarea>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="foot__note">
|
||||
<span class="foot__dot" :class="willQueue ? 'foot__dot--queue' : 'foot__dot--instant'"></span>
|
||||
<template v-if="willQueue">已加入抓取队列,索引完成后会通知你。</template>
|
||||
<template v-else>立即添加,无需抓取。</template>
|
||||
</span>
|
||||
<div class="foot__actions">
|
||||
<button class="btn btn--ghost" type="button" :disabled="submitting" @click="emit('close')">取消</button>
|
||||
<button class="btn btn--primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
{{ submitting ? '添加中…' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.modal__sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.types {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px;
|
||||
padding: 18px 24px 4px;
|
||||
}
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 13px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--o10);
|
||||
}
|
||||
|
||||
.type:hover {
|
||||
background: var(--o04);
|
||||
}
|
||||
|
||||
.type--active {
|
||||
background: var(--accent-bg-06);
|
||||
border-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.type__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.type__sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px 24px 4px;
|
||||
min-height: 172px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field-label:not(:first-child) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.text-input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 120px;
|
||||
line-height: 1.6;
|
||||
color: var(--text3);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.text-input:focus,
|
||||
.textarea:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
}
|
||||
|
||||
.scope {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scope__opt {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 12px 13px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--o10);
|
||||
}
|
||||
|
||||
.scope__opt--active {
|
||||
background: var(--accent-bg-06);
|
||||
border-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--o20);
|
||||
}
|
||||
|
||||
.radio--on {
|
||||
border-color: var(--accent-solid);
|
||||
background: radial-gradient(circle, var(--accent-solid) 0 45%, transparent 48%);
|
||||
}
|
||||
|
||||
.scope__title {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.scope__sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
margin-top: 1px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.scope__hint {
|
||||
margin: 8px 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.drop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 30px 20px;
|
||||
background: var(--bg);
|
||||
border: 1.5px dashed var(--o20);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drop--over {
|
||||
border-color: var(--c-coral);
|
||||
background: var(--coral-bg);
|
||||
}
|
||||
|
||||
.hidden-file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drop__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.drop__hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: 11px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.note--teal {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
}
|
||||
|
||||
.note--coral {
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--o08);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.foot__note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.foot__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.foot__dot--queue {
|
||||
background: var(--c-purple);
|
||||
}
|
||||
|
||||
.foot__dot--instant {
|
||||
background: var(--c-teal);
|
||||
}
|
||||
|
||||
.foot__actions {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--o05);
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,505 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
useKnowledgeExplorer,
|
||||
type ExplorerSource,
|
||||
type AddSourcePayload,
|
||||
} from '@/composables/useKnowledgeExplorer'
|
||||
import KnowledgeSourceTree from './KnowledgeSourceTree.vue'
|
||||
import KnowledgePageDetail from './KnowledgePageDetail.vue'
|
||||
import KnowledgePageEditor from './KnowledgePageEditor.vue'
|
||||
import KnowledgePlanMeters from './KnowledgePlanMeters.vue'
|
||||
import KnowledgeAddSourceModal from './KnowledgeAddSourceModal.vue'
|
||||
import KnowledgeLinkModal from './KnowledgeLinkModal.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode: 'agent' | 'org'
|
||||
organizationId: string
|
||||
agentId?: string
|
||||
showPlanMeters?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: 'page' | 'section'
|
||||
}>(),
|
||||
{
|
||||
agentId: undefined,
|
||||
showPlanMeters: false,
|
||||
title: '',
|
||||
description: '',
|
||||
variant: 'section',
|
||||
},
|
||||
)
|
||||
|
||||
const ex = useKnowledgeExplorer(props.mode, props.agentId, props.organizationId)
|
||||
|
||||
// Add-source modal state.
|
||||
const addOpen = ref(false)
|
||||
const isSubmittingSource = ref(false)
|
||||
|
||||
// A single, reusable confirm dialog for the destructive actions.
|
||||
interface ConfirmState {
|
||||
title: string
|
||||
message: string
|
||||
actionLabel: string
|
||||
busyLabel: string
|
||||
action: () => Promise<void>
|
||||
}
|
||||
const confirmState = ref<ConfirmState | null>(null)
|
||||
|
||||
const largestSource = computed(() =>
|
||||
ex.sources.value.reduce<ExplorerSource | null>((max, s) => {
|
||||
if (!max || (s.pages ?? s.pageStubs).length > (max.pages ?? max.pageStubs).length) return s
|
||||
return max
|
||||
}, null),
|
||||
)
|
||||
const largestSubpageCount = computed(() => {
|
||||
const s = largestSource.value
|
||||
return s ? (s.pages ?? s.pageStubs).length : 0
|
||||
})
|
||||
|
||||
function openAdd() {
|
||||
addOpen.value = true
|
||||
}
|
||||
|
||||
async function onSubmitSource(payload: AddSourcePayload) {
|
||||
if (isSubmittingSource.value) return
|
||||
isSubmittingSource.value = true
|
||||
try {
|
||||
const ok = await ex.submitSource(payload)
|
||||
if (ok) addOpen.value = false
|
||||
} finally {
|
||||
isSubmittingSource.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function askDeleteSource(source: ExplorerSource) {
|
||||
if (source.queued && source.queuedStatus === 'error') {
|
||||
// A failed crawl — not an in-flight one to "cancel".
|
||||
confirmState.value = {
|
||||
title: '忽略来源',
|
||||
message: `忽略失败的来源“${source.name}”?`,
|
||||
actionLabel: '忽略',
|
||||
busyLabel: '正在忽略…',
|
||||
action: () => ex.deleteSource(source),
|
||||
}
|
||||
} else if (source.queued) {
|
||||
confirmState.value = {
|
||||
title: '取消抓取',
|
||||
message: `取消对“${source.name}”的排队抓取?`,
|
||||
actionLabel: '取消抓取',
|
||||
busyLabel: '正在取消…',
|
||||
action: () => ex.deleteSource(source),
|
||||
}
|
||||
} else if (props.mode === 'agent') {
|
||||
// In an agent's tab, "Remove" detaches the source from this agent but keeps
|
||||
// it in the organization's knowledge — it does not delete the source.
|
||||
confirmState.value = {
|
||||
title: '从智能体移除',
|
||||
message: `从该智能体移除“${source.name}”?它仍会保留在组织的知识库中。`,
|
||||
actionLabel: '移除',
|
||||
busyLabel: '正在移除…',
|
||||
action: () => ex.unlinkSource(source.id),
|
||||
}
|
||||
} else {
|
||||
confirmState.value = {
|
||||
title: '删除来源',
|
||||
message: `删除“${source.name}”及其所有页面?此操作无法撤销。`,
|
||||
actionLabel: '删除',
|
||||
busyLabel: '正在删除…',
|
||||
action: () => ex.deleteSource(source),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function askDeletePage() {
|
||||
const page = ex.selectedPage.value
|
||||
if (!page) return
|
||||
confirmState.value = {
|
||||
title: '删除页面',
|
||||
message: `从该来源删除“${page.title}”?此操作无法撤销。`,
|
||||
actionLabel: '删除',
|
||||
busyLabel: '正在删除…',
|
||||
action: () => ex.deletePage(),
|
||||
}
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
const state = confirmState.value
|
||||
if (!state) return
|
||||
await state.action()
|
||||
confirmState.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ex.refresh()
|
||||
ex.startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ex.stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="explorer">
|
||||
<header class="explorer__header">
|
||||
<div class="explorer__heading">
|
||||
<component :is="variant === 'page' ? 'h1' : 'h3'" class="explorer__title" :class="`explorer__title--${variant}`">
|
||||
{{ title }}
|
||||
</component>
|
||||
<p v-if="description" class="explorer__desc">{{ description }}</p>
|
||||
</div>
|
||||
<div class="explorer__actions">
|
||||
<slot name="actions" />
|
||||
<button v-if="mode === 'agent'" class="btn btn--ghost" type="button" @click="ex.openLinkPicker">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></svg>
|
||||
关联已有
|
||||
</button>
|
||||
<button class="btn btn--primary" type="button" @click="openAdd">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
添加来源
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<KnowledgePlanMeters
|
||||
v-if="showPlanMeters"
|
||||
class="explorer__meters"
|
||||
:source-count="ex.sources.value.length"
|
||||
:largest-source-name="largestSource?.name ?? null"
|
||||
:largest-subpage-count="largestSubpageCount"
|
||||
/>
|
||||
|
||||
<div v-if="ex.error.value" class="banner" role="alert">
|
||||
{{ ex.error.value }}
|
||||
<button class="banner__close" type="button" aria-label="关闭" @click="ex.error.value = null">×</button>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<KnowledgeSourceTree
|
||||
class="grid__tree"
|
||||
:sources="ex.filteredSources.value"
|
||||
:selected-page-id="ex.selectedPageId.value"
|
||||
:selected-source-id="ex.selectedSourceId.value"
|
||||
:query="ex.query.value"
|
||||
:status-of="ex.sourceStatus"
|
||||
:page-rows-of="ex.pageRows"
|
||||
@update:query="ex.query.value = $event"
|
||||
@toggle="ex.toggleSource"
|
||||
@select="(source, pageId) => ex.selectPage(source, pageId)"
|
||||
@delete-source="askDeleteSource"
|
||||
@add-page="ex.startAddPage"
|
||||
/>
|
||||
|
||||
<div class="grid__detail">
|
||||
<KnowledgePageEditor
|
||||
v-if="ex.editing.value && (ex.selectedPage.value || ex.isAddingPage.value)"
|
||||
:title="ex.draftTitle.value"
|
||||
:content="ex.draftContent.value"
|
||||
:saving="ex.isSaving.value"
|
||||
:submit-label="ex.isAddingPage.value ? '添加子页面' : '保存页面'"
|
||||
:url="ex.draftUrl.value"
|
||||
:show-url="ex.isAddingPage.value"
|
||||
@update:title="ex.draftTitle.value = $event"
|
||||
@update:content="ex.draftContent.value = $event"
|
||||
@update:url="ex.draftUrl.value = $event"
|
||||
@save="ex.savePage"
|
||||
@cancel="ex.cancelEdit"
|
||||
/>
|
||||
<KnowledgePageDetail
|
||||
v-else-if="ex.selectedPage.value && ex.selectedSource.value"
|
||||
:page="ex.selectedPage.value"
|
||||
:source-name="ex.selectedSource.value.name"
|
||||
:source-type="ex.selectedSource.value.type"
|
||||
:agents="ex.selectedSource.value.agents"
|
||||
:status="ex.sourceStatus(ex.selectedSource.value)"
|
||||
:deleting="ex.isDeleting.value"
|
||||
@edit="ex.startEdit"
|
||||
@delete="askDeletePage"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<div class="empty__orb"></div>
|
||||
<div class="empty__title">选择一个页面查看</div>
|
||||
<p class="empty__text">
|
||||
在左侧选择任一已提取的页面,即可查看内容、编辑或移除,也可以添加自己的来源。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="confirmState" class="confirm" role="dialog" aria-modal="true">
|
||||
<div class="confirm__card">
|
||||
<h3 class="confirm__title">{{ confirmState.title }}</h3>
|
||||
<p class="confirm__msg">{{ confirmState.message }}</p>
|
||||
<div class="confirm__actions">
|
||||
<button class="btn btn--ghost" type="button" :disabled="ex.isDeleting.value" @click="confirmState = null">取消</button>
|
||||
<button class="btn btn--danger-solid" type="button" :disabled="ex.isDeleting.value" @click="runConfirm">
|
||||
{{ ex.isDeleting.value ? confirmState.busyLabel : confirmState.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KnowledgeAddSourceModal
|
||||
v-if="addOpen"
|
||||
:submitting="isSubmittingSource"
|
||||
@close="addOpen = false"
|
||||
@submit="onSubmitSource"
|
||||
/>
|
||||
|
||||
<KnowledgeLinkModal
|
||||
v-if="ex.linkPickerOpen.value"
|
||||
:sources="ex.orgSources.value"
|
||||
:linked-ids="ex.linkedSourceIds.value"
|
||||
:busy-ids="ex.linkingIds.value"
|
||||
:loading="ex.isLoadingOrgSources.value"
|
||||
:error="ex.orgSourcesError.value"
|
||||
@close="ex.linkPickerOpen.value = false"
|
||||
@link="ex.linkSource"
|
||||
@unlink="ex.unlinkSource"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.explorer__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.explorer__title--page {
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.explorer__title--section {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.explorer__desc {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.explorer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explorer__meters {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 47px;
|
||||
padding: 0 18px;
|
||||
border-radius: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.btn--ghost {
|
||||
background: var(--o05);
|
||||
border-color: var(--o12);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--o08); }
|
||||
|
||||
.btn--danger-solid {
|
||||
background: var(--c-coral);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--danger-solid:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
border-radius: 11px;
|
||||
color: var(--c-coral);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.banner__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 344px 1fr;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.grid__tree,
|
||||
.grid__detail {
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
.grid__detail {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.empty__orb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, var(--c-lime), var(--c-purple), var(--c-teal), var(--c-coral), var(--c-lime));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.empty__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--text2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty__text {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
max-width: 320px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.confirm__card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.confirm__msg {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.confirm__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { KnowledgeItem } from '@/types/knowledge'
|
||||
|
||||
const props = defineProps<{
|
||||
sources: KnowledgeItem[]
|
||||
linkedIds: Set<number>
|
||||
busyIds: Set<number>
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'link', knowledgeId: number): void
|
||||
(e: 'unlink', knowledgeId: number): void
|
||||
}>()
|
||||
|
||||
const query = ref('')
|
||||
|
||||
const filteredSources = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return props.sources
|
||||
return props.sources.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || s.type.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scrim" @click.self="emit('close')">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="关联已有知识">
|
||||
<div class="modal__head">
|
||||
<div>
|
||||
<h3 class="modal__title">关联已有知识</h3>
|
||||
<p class="modal__sub">把组织中的知识关联到该智能体,供其使用。</p>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input v-model="query" class="text-input" type="text" placeholder="搜索知识来源…" />
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div v-if="loading" class="state">正在加载知识来源…</div>
|
||||
<div v-else-if="error" class="state state--error">{{ error }}</div>
|
||||
<div v-else-if="!filteredSources.length" class="state">
|
||||
{{ query ? '没有匹配的来源。' : '组织中还没有可关联的知识。' }}
|
||||
</div>
|
||||
<ul v-else class="list">
|
||||
<li v-for="item in filteredSources" :key="item.id" class="row">
|
||||
<div class="row__main">
|
||||
<span class="row__name">{{ item.name }}</span>
|
||||
<span class="row__type">{{ item.type }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="linkedIds.has(item.id)"
|
||||
class="btn btn--linked"
|
||||
type="button"
|
||||
:disabled="busyIds.has(item.id)"
|
||||
@click="emit('unlink', item.id)"
|
||||
>
|
||||
{{ busyIds.has(item.id) ? '正在移除…' : '取消关联' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn--primary"
|
||||
type="button"
|
||||
:disabled="busyIds.has(item.id)"
|
||||
@click="emit('link', item.id)"
|
||||
>
|
||||
{{ busyIds.has(item.id) ? '正在关联…' : '关联' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<button class="btn btn--ghost" type="button" @click="emit('close')">完成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.modal__sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 16px 24px 4px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 24px 4px;
|
||||
min-height: 180px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state--error {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--o10);
|
||||
background: var(--o04);
|
||||
}
|
||||
|
||||
.row__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.row__name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.row__type {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--o08);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn--linked {
|
||||
background: var(--o05);
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--linked:hover:not(:disabled) {
|
||||
background: var(--coral-bg);
|
||||
border-color: var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--o05);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { KnowledgeLinkedAgent, KnowledgeSubPage } from '@/types/knowledge'
|
||||
import type { SourceStatus } from '@/composables/useKnowledgeExplorer'
|
||||
|
||||
const props = defineProps<{
|
||||
page: KnowledgeSubPage
|
||||
sourceName: string
|
||||
sourceType: string
|
||||
agents?: KnowledgeLinkedAgent[]
|
||||
status: SourceStatus
|
||||
deleting: boolean
|
||||
}>()
|
||||
|
||||
// First-letter avatar for an agent chip.
|
||||
const initial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const statusText: Record<SourceStatus, string> = {
|
||||
queued: '等待中',
|
||||
crawling: '抓取中',
|
||||
synced: '已同步',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
const paragraphs = computed(() =>
|
||||
(props.page.content || '')
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
const href = computed(() => {
|
||||
const url = props.page.url || ''
|
||||
return /^https?:/i.test(url) ? url : `https://${url}`
|
||||
})
|
||||
|
||||
const updatedLabel = computed(() => {
|
||||
if (!props.page.updated_at) return '未知'
|
||||
const d = new Date(props.page.updated_at)
|
||||
return Number.isNaN(d.getTime()) ? '未知' : d.toLocaleDateString()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail">
|
||||
<div class="detail__head">
|
||||
<div class="crumbs">
|
||||
<span class="crumbs__src" :title="sourceName">{{ sourceName }}</span>
|
||||
<span class="crumbs__sep">/</span>
|
||||
<span class="crumbs__type">{{ sourceType }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail__title-row">
|
||||
<div class="detail__title-wrap">
|
||||
<h2 class="detail__title">{{ page.title }}</h2>
|
||||
<a class="detail__url" :href="href" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<path d="M15 3h6v6" /><path d="M10 14 21 3" />
|
||||
</svg>
|
||||
<span class="detail__url-text">{{ page.url }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="detail__actions">
|
||||
<button class="btn btn--ghost" type="button" @click="$emit('edit')">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button class="btn btn--danger" type="button" :disabled="deleting" title="删除页面"
|
||||
@click="$emit('delete')">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="meta__item">{{ page.word_count }} 词</span>
|
||||
<span class="meta__sep"></span>
|
||||
<span class="meta__item">更新于 {{ updatedLabel }}</span>
|
||||
<span class="meta__sep"></span>
|
||||
<span class="pill" :class="`pill--${status}`">
|
||||
<span class="pill__dot"></span>{{ statusText[status] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="agents && agents.length" class="usedby">
|
||||
<span class="usedby__label">使用方</span>
|
||||
<span v-for="agent in agents" :key="agent.id" class="agent-chip" :title="agent.name">
|
||||
<span class="agent-chip__avatar">{{ initial(agent.name) }}</span>
|
||||
<span class="agent-chip__name">{{ agent.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc">
|
||||
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
|
||||
<div v-if="paragraphs.length === 0" class="doc__empty">
|
||||
该页面还没有内容,点击 <strong>编辑</strong> 添加内容。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail__head {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumbs__src {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.crumbs__sep { color: var(--faint); }
|
||||
.crumbs__type { color: var(--text3); }
|
||||
|
||||
.detail__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail__title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail__url {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--c-teal);
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.detail__url-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--o12);
|
||||
background: var(--o05);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--o08); }
|
||||
|
||||
.btn--danger {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.meta__sep {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--teal-border);
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.pill__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.usedby {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.usedby__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.agent-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px 3px 3px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--o12);
|
||||
background: var(--o05);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.agent-chip__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-chip__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill--crawling {
|
||||
border-color: var(--warning-border, var(--o12));
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.pill--queued {
|
||||
border-color: var(--purple-border, var(--o12));
|
||||
background: var(--purple-bg, var(--o06));
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.pill--error {
|
||||
border-color: var(--coral-border);
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.doc {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
color: var(--text3);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.doc p {
|
||||
margin: 0 0 14px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.doc__empty {
|
||||
text-align: center;
|
||||
color: var(--muted2);
|
||||
font-size: 14px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
content: string
|
||||
saving: boolean
|
||||
submitLabel?: string
|
||||
url?: string
|
||||
showUrl?: boolean
|
||||
}>(),
|
||||
{ submitLabel: '保存页面', url: '', showUrl: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:title', value: string): void
|
||||
(e: 'update:content', value: string): void
|
||||
(e: 'update:url', value: string): void
|
||||
(e: 'save'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div class="editor__body">
|
||||
<label class="editor__label" for="kb-page-title">页面标题</label>
|
||||
<input
|
||||
id="kb-page-title"
|
||||
class="editor__title"
|
||||
:value="title"
|
||||
type="text"
|
||||
placeholder="页面标题"
|
||||
@input="emit('update:title', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
|
||||
<template v-if="showUrl">
|
||||
<label class="editor__label" for="kb-page-url">来源 URL(可选)</label>
|
||||
<input
|
||||
id="kb-page-url"
|
||||
class="editor__title editor__url"
|
||||
:value="url"
|
||||
type="url"
|
||||
placeholder="https://example.com/page"
|
||||
@input="emit('update:url', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<label class="editor__label" for="kb-page-content">页面内容</label>
|
||||
<textarea
|
||||
id="kb-page-content"
|
||||
class="editor__content"
|
||||
:value="content"
|
||||
placeholder="在此输入页面内容,段落之间留一个空行。"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="editor__foot">
|
||||
<span class="editor__status">
|
||||
<span class="editor__status-dot"></span>编辑中——保存后重新生成向量
|
||||
</span>
|
||||
<div class="editor__buttons">
|
||||
<button class="btn btn--ghost" type="button" :disabled="saving" @click="emit('cancel')">取消</button>
|
||||
<button class="btn btn--primary" type="button" :disabled="!canSave" @click="emit('save')">
|
||||
{{ saving ? '保存中…' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.editor__title {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.editor__url {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.editor__title:focus,
|
||||
.editor__content:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
}
|
||||
|
||||
.editor__content {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--text3);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.editor__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--o10);
|
||||
background: var(--bg2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor__status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-coral);
|
||||
}
|
||||
|
||||
.editor__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 18px;
|
||||
border-radius: 9px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--o05); }
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
const props = defineProps<{
|
||||
sourceCount: number
|
||||
largestSourceName: string | null
|
||||
largestSubpageCount: number
|
||||
}>()
|
||||
|
||||
const { hasEnterpriseModule, subscriptionStore, initializeSubscriptionStore } =
|
||||
useEnterpriseFeatures()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!hasEnterpriseModule) return
|
||||
await initializeSubscriptionStore()
|
||||
if (!subscriptionStore.value.currentPlan) {
|
||||
await subscriptionStore.value.fetchCurrentPlan()
|
||||
}
|
||||
})
|
||||
|
||||
const plan = computed(() => subscriptionStore.value.currentPlan?.plan ?? null)
|
||||
const maxSources = computed<number | null>(() => plan.value?.max_knowledge_sources ?? null)
|
||||
const maxSubpages = computed<number | null>(() => plan.value?.max_sub_pages ?? null)
|
||||
|
||||
// Clamp a usage ratio to a readable meter width (min 6% so a tiny bar still shows).
|
||||
function pct(used: number, limit: number | null): number {
|
||||
if (!limit || limit <= 0) return 0
|
||||
return Math.max(6, Math.min(100, Math.round((used / limit) * 100)))
|
||||
}
|
||||
|
||||
const sourcesOver = computed(() => maxSources.value !== null && props.sourceCount > maxSources.value)
|
||||
const subpagesOver = computed(
|
||||
() => maxSubpages.value !== null && props.largestSubpageCount > maxSubpages.value,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="meters">
|
||||
<div class="meter" :class="{ 'meter--over': sourcesOver }">
|
||||
<div class="meter__head">
|
||||
<span class="meter__label">知识来源</span>
|
||||
<span class="meter__value">
|
||||
{{ sourceCount }}<template v-if="maxSources !== null"> / {{ maxSources }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="maxSources !== null" class="meter__track">
|
||||
<div class="meter__fill" :style="{ width: pct(sourceCount, maxSources) + '%' }"></div>
|
||||
</div>
|
||||
<div class="meter__note">
|
||||
<template v-if="sourcesOver">已超出套餐的来源数量上限,升级后可关联更多来源。</template>
|
||||
<template v-else-if="maxSources !== null">已关联 {{ sourceCount }} / {{ maxSources }} 个来源。</template>
|
||||
<template v-else>已关联 {{ sourceCount }} 个来源。</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meter" :class="{ 'meter--over': subpagesOver }">
|
||||
<div class="meter__head">
|
||||
<span class="meter__label">每个来源的子页面</span>
|
||||
<span class="meter__value">
|
||||
<template v-if="maxSubpages !== null">最多 {{ maxSubpages }}</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="maxSubpages !== null" class="meter__track">
|
||||
<div class="meter__fill meter__fill--alt"
|
||||
:style="{ width: pct(largestSubpageCount, maxSubpages) + '%' }"></div>
|
||||
</div>
|
||||
<div class="meter__note">
|
||||
<template v-if="largestSourceName">
|
||||
最大来源:{{ largestSourceName }} — {{ largestSubpageCount }}<template v-if="maxSubpages !== null"> / {{ maxSubpages }}</template> 个子页面
|
||||
</template>
|
||||
<template v-else>还没有来源。</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.meters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.meter {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.meter__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.meter__label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.meter__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.meter--over .meter__value {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.meter__track {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--o07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--c-teal);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.meter__fill--alt {
|
||||
background: var(--c-purple);
|
||||
}
|
||||
|
||||
.meter--over .meter__fill {
|
||||
background: var(--c-coral);
|
||||
}
|
||||
|
||||
.meter__note {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meter--over .meter__note {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,501 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ExplorerSource, SourceStatus } from '@/composables/useKnowledgeExplorer'
|
||||
|
||||
interface PageRow {
|
||||
page_id: string
|
||||
title: string
|
||||
words: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sources: ExplorerSource[]
|
||||
selectedPageId: string | null
|
||||
selectedSourceId: number | null
|
||||
query: string
|
||||
statusOf: (source: ExplorerSource) => SourceStatus
|
||||
pageRowsOf: (source: ExplorerSource) => PageRow[]
|
||||
}>()
|
||||
|
||||
// Group page rows once per render (the template reads them twice per source).
|
||||
const rowsBySource = computed(() => {
|
||||
const map = new Map<number, PageRow[]>()
|
||||
for (const source of props.sources) map.set(source.id, props.pageRowsOf(source))
|
||||
return map
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:query', value: string): void
|
||||
(e: 'toggle', source: ExplorerSource): void
|
||||
(e: 'select', source: ExplorerSource, pageId: string): void
|
||||
(e: 'delete-source', source: ExplorerSource): void
|
||||
(e: 'add-page', source: ExplorerSource): void
|
||||
}>()
|
||||
|
||||
const statusLabel: Record<SourceStatus, string> = {
|
||||
queued: '等待中',
|
||||
crawling: '抓取中',
|
||||
synced: '已同步',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
// A queued placeholder's error state is a failed crawl, not a source that
|
||||
// merely "needs sync".
|
||||
function queuedLabel(source: ExplorerSource): string {
|
||||
const status = props.statusOf(source)
|
||||
return status === 'error' ? '失败' : statusLabel[status]
|
||||
}
|
||||
|
||||
function sourceGlyph(type: string): string {
|
||||
const t = (type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t === 'file') return 'P'
|
||||
if (t.includes('site')) return 'S'
|
||||
if (t.includes('web')) return 'W'
|
||||
return 'T'
|
||||
}
|
||||
|
||||
// First-letter avatar for a linked agent.
|
||||
const agentInitial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
// Show at most this many agent avatars on a source row; the rest fold into "+N".
|
||||
const MAX_AVATARS = 3
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tree">
|
||||
<div class="tree__search">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
:value="query"
|
||||
type="search"
|
||||
placeholder="搜索页面…"
|
||||
aria-label="搜索页面"
|
||||
@input="emit('update:query', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tree__body">
|
||||
<div v-for="source in sources" :key="source.id" class="src">
|
||||
<div class="src__row">
|
||||
<button
|
||||
class="src__toggle"
|
||||
type="button"
|
||||
:aria-expanded="source.queued ? undefined : source.expanded"
|
||||
:disabled="source.queued"
|
||||
@click="!source.queued && emit('toggle', source)"
|
||||
>
|
||||
<svg v-if="!source.queued" class="chev" :class="{ 'chev--open': source.expanded }" viewBox="0 0 24 24"
|
||||
width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
<span v-else class="chev-spacer"></span>
|
||||
<span class="src__glyph" :class="`src__glyph--${sourceGlyph(source.type).toLowerCase()}`">{{ sourceGlyph(source.type) }}</span>
|
||||
<span class="src__meta">
|
||||
<span class="src__name" :title="source.name">{{ source.name }}</span>
|
||||
<span class="src__count" :class="{ 'src__count--error': source.queued && statusOf(source) === 'error' }">
|
||||
{{ source.queued ? queuedLabel(source) : (source.pages ?? source.pageStubs).length + ' 个子页面' }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="!source.queued && source.agents.length"
|
||||
class="agents"
|
||||
:title="`使用方:${source.agents.map((a) => a.name).join('、')}`"
|
||||
>
|
||||
<span
|
||||
v-for="agent in source.agents.slice(0, MAX_AVATARS)"
|
||||
:key="agent.id"
|
||||
class="agents__avatar"
|
||||
>{{ agentInitial(agent.name) }}</span>
|
||||
<span v-if="source.agents.length > MAX_AVATARS" class="agents__more">
|
||||
+{{ source.agents.length - MAX_AVATARS }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="dot" :class="`dot--${statusOf(source)}`" :title="statusLabel[statusOf(source)]"></span>
|
||||
<button
|
||||
v-if="!source.queued"
|
||||
class="src__add"
|
||||
type="button"
|
||||
title="为该来源添加子页面"
|
||||
@click="emit('add-page', source)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="src__del"
|
||||
type="button"
|
||||
:title="!source.queued ? '删除此来源' : (statusOf(source) === 'error' ? '忽略失败的来源' : '取消抓取')"
|
||||
@click="emit('delete-source', source)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="source.queued && source.queuedError" class="src__error">
|
||||
{{ source.queuedError }}
|
||||
</div>
|
||||
|
||||
<div v-if="source.expanded && !source.queued" class="src__pages">
|
||||
<div v-if="source.loadingContent" class="src__hint">正在加载页面…</div>
|
||||
<div v-else-if="source.contentError" class="src__hint src__hint--error">{{ source.contentError }}</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="row in (rowsBySource.get(source.id) || [])"
|
||||
:key="row.page_id"
|
||||
type="button"
|
||||
class="pg"
|
||||
:class="{ 'pg--active': source.id === selectedSourceId && row.page_id === selectedPageId }"
|
||||
@click="emit('select', source, row.page_id)"
|
||||
>
|
||||
<span class="pg__dot"></span>
|
||||
<span class="pg__title" :title="row.title">{{ row.title }}</span>
|
||||
<span v-if="row.words !== null" class="pg__words">{{ row.words }} 词</span>
|
||||
</button>
|
||||
<div v-if="(rowsBySource.get(source.id) || []).length === 0" class="src__hint">尚未提取任何页面。</div>
|
||||
<button type="button" class="pg pg--add" @click="emit('add-page', source)">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
添加子页面
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sources.length === 0" class="tree__empty">
|
||||
<template v-if="query">没有匹配“{{ query }}”的页面。</template>
|
||||
<template v-else>还没有知识来源。</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 14px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 10px;
|
||||
color: var(--muted2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree__search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.tree__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.src {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.src__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 9px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.src__row:hover {
|
||||
background: var(--o04);
|
||||
}
|
||||
|
||||
.src__toggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted2);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.chev-spacer {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.src__toggle:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chev--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.src__glyph {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.src__glyph--p { background: var(--coral-bg); color: var(--c-coral); }
|
||||
.src__glyph--s { background: var(--teal-bg); color: var(--c-teal); }
|
||||
.src__glyph--t { background: var(--purple-bg, var(--o08)); color: var(--c-purple); }
|
||||
|
||||
.src__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.src__name {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.src__count {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted2);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.src__count--error {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.agents {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agents__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border: 1.5px solid var(--surface);
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
.agents__avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.agents__more {
|
||||
margin-left: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--muted2);
|
||||
}
|
||||
|
||||
.dot--synced { background: var(--c-teal); box-shadow: 0 0 6px var(--c-teal); }
|
||||
.dot--crawling { background: var(--warning-color); }
|
||||
.dot--queued { background: var(--c-purple); }
|
||||
.dot--error { background: var(--c-coral); }
|
||||
|
||||
.src__add,
|
||||
.src__del {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.src__row:hover .src__add,
|
||||
.src__row:hover .src__del { opacity: 1; }
|
||||
|
||||
.src__add:hover {
|
||||
background: var(--accent-bg-08);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent-border, var(--o12));
|
||||
}
|
||||
|
||||
.src__del:hover {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
}
|
||||
|
||||
.src__pages {
|
||||
padding: 2px 0 6px 18px;
|
||||
}
|
||||
|
||||
.pg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin: 1px 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.pg:hover { background: var(--o04); }
|
||||
.pg--active { background: var(--accent-bg-08); }
|
||||
|
||||
.pg--add {
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.pg--add:hover {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.pg__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--c-teal);
|
||||
}
|
||||
|
||||
.pg__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.pg--active .pg__title {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pg__words {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.src__hint {
|
||||
padding: 8px 10px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.src__hint--error { color: var(--c-coral); }
|
||||
|
||||
.src__error {
|
||||
margin: 2px 10px 6px 44px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--c-coral);
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tree__empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user