1117 lines
32 KiB
Vue
1117 lines
32 KiB
Vue
<script setup lang="ts">
|
|
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
|
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
|
|
import { marked } from 'marked'
|
|
import { MessagePlugin } from 'tdesign-vue-next'
|
|
import { useUIStore } from '@/stores/ui'
|
|
import {
|
|
listKnowledgeBases,
|
|
getKnowledgeDetails,
|
|
getKnowledgeBaseById,
|
|
createManualKnowledge,
|
|
updateManualKnowledge,
|
|
} from '@/api/knowledge-base'
|
|
import { useUploadConfirmStore } from '@/stores/uploadConfirm'
|
|
import { useOrganizationStore } from '@/stores/organization'
|
|
import type { KnowledgeProcessOverrides } from '@/types/knowledgeProcess'
|
|
import { sanitizeHTML, safeMarkdownToHTML } from '@/utils/security'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
interface KnowledgeBaseOption {
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
interface KnowledgeDetailResponse {
|
|
id: string
|
|
knowledge_base_id: string
|
|
title?: string
|
|
file_name?: string
|
|
metadata?: any
|
|
parse_status?: string
|
|
}
|
|
|
|
type ManualStatus = 'draft' | 'publish'
|
|
|
|
/** Derive editor status from metadata + parse_status (parse pipeline wins when indexed or in flight). */
|
|
const resolveManualKnowledgeStatus = (
|
|
metaStatus: ManualStatus | undefined,
|
|
parseStatus?: string,
|
|
): ManualStatus => {
|
|
if (!parseStatus || parseStatus === 'draft') {
|
|
return metaStatus === 'publish' ? 'publish' : 'draft'
|
|
}
|
|
if (
|
|
parseStatus === 'completed' ||
|
|
parseStatus === 'pending' ||
|
|
parseStatus === 'processing' ||
|
|
parseStatus === 'finalizing'
|
|
) {
|
|
return 'publish'
|
|
}
|
|
return metaStatus === 'publish' ? 'publish' : 'draft'
|
|
}
|
|
|
|
const uiStore = useUIStore()
|
|
const uploadConfirmStore = useUploadConfirmStore()
|
|
const organizationStore = useOrganizationStore()
|
|
const { t } = useI18n()
|
|
|
|
const visible = computed({
|
|
get: () => uiStore.manualEditorVisible,
|
|
set: (val: boolean) => {
|
|
if (!val) {
|
|
handleClose()
|
|
}
|
|
},
|
|
})
|
|
|
|
const mode = computed(() => uiStore.manualEditorMode)
|
|
const knowledgeId = computed(() => uiStore.manualEditorKnowledgeId)
|
|
|
|
const form = reactive({
|
|
kbId: '' as string,
|
|
title: '',
|
|
content: '',
|
|
status: 'draft' as ManualStatus,
|
|
})
|
|
|
|
const initialLoaded = ref(false)
|
|
const kbOptions = ref<KnowledgeBaseOption[]>([])
|
|
const kbLoading = ref(false)
|
|
const contentLoading = ref(false)
|
|
const saving = ref(false)
|
|
const savingAction = ref<ManualStatus>('draft')
|
|
const activeTab = ref<'edit' | 'preview'>('edit')
|
|
const lastUpdatedAt = ref<string>('')
|
|
|
|
const textareaComponent = ref<any>(null)
|
|
const textareaElement = ref<HTMLTextAreaElement | null>(null)
|
|
const selectionRange = reactive({ start: 0, end: 0 })
|
|
const selectionEvents = ['select', 'keyup', 'click', 'mouseup', 'input']
|
|
|
|
const resolveTextareaElement = (): HTMLTextAreaElement | null => {
|
|
const component = textareaComponent.value as any
|
|
if (!component) return null
|
|
if (component.textareaRef) {
|
|
return component.textareaRef as HTMLTextAreaElement
|
|
}
|
|
if (component.$el) {
|
|
const el = component.$el.querySelector('textarea')
|
|
if (el) {
|
|
return el as HTMLTextAreaElement
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
const handleTextareaSelectionEvent = () => {
|
|
const textarea = textareaElement.value ?? resolveTextareaElement()
|
|
if (!textarea) {
|
|
return
|
|
}
|
|
selectionRange.start = textarea.selectionStart ?? 0
|
|
selectionRange.end = textarea.selectionEnd ?? 0
|
|
}
|
|
|
|
const detachTextareaListeners = () => {
|
|
if (!textareaElement.value) {
|
|
return
|
|
}
|
|
selectionEvents.forEach((eventName) => {
|
|
textareaElement.value?.removeEventListener(eventName, handleTextareaSelectionEvent)
|
|
})
|
|
textareaElement.value = null
|
|
}
|
|
|
|
const attachTextareaListeners = () => {
|
|
nextTick(() => {
|
|
const textarea = resolveTextareaElement()
|
|
if (!textarea) {
|
|
return
|
|
}
|
|
if (textareaElement.value === textarea) {
|
|
return
|
|
}
|
|
detachTextareaListeners()
|
|
textareaElement.value = textarea
|
|
selectionEvents.forEach((eventName) => {
|
|
textarea.addEventListener(eventName, handleTextareaSelectionEvent)
|
|
})
|
|
handleTextareaSelectionEvent()
|
|
})
|
|
}
|
|
|
|
const setSelectionRange = (start: number, end: number) => {
|
|
selectionRange.start = start
|
|
selectionRange.end = end
|
|
nextTick(() => {
|
|
const textarea = resolveTextareaElement()
|
|
if (!textarea || activeTab.value !== 'edit') {
|
|
return
|
|
}
|
|
// Initialization can finish while the drawer is still sliding in. A plain
|
|
// focus() makes the browser scroll the transformed textarea into view,
|
|
// which intermittently shifts the drawer away from the right edge for a
|
|
// frame. Keep keyboard focus without letting it move the viewport.
|
|
textarea.focus({ preventScroll: true })
|
|
textarea.setSelectionRange(start, end)
|
|
})
|
|
}
|
|
|
|
const getSelectionRange = () => {
|
|
return {
|
|
start: selectionRange.start ?? 0,
|
|
end: selectionRange.end ?? 0,
|
|
}
|
|
}
|
|
|
|
const clampRange = (start: number, end: number, length: number) => {
|
|
let safeStart = Math.max(0, Math.min(start, length))
|
|
let safeEnd = Math.max(0, Math.min(end, length))
|
|
if (safeEnd < safeStart) {
|
|
;[safeStart, safeEnd] = [safeEnd, safeStart]
|
|
}
|
|
return { safeStart, safeEnd }
|
|
}
|
|
|
|
const updateContentWithSelection = (content: string, start: number, end: number) => {
|
|
form.content = content
|
|
setSelectionRange(start, end)
|
|
}
|
|
|
|
const findLineStart = (value: string, index: number) => {
|
|
if (index <= 0) return 0
|
|
const lastNewline = value.lastIndexOf('\n', index - 1)
|
|
return lastNewline === -1 ? 0 : lastNewline + 1
|
|
}
|
|
|
|
const findLineEnd = (value: string, index: number) => {
|
|
if (index >= value.length) return value.length
|
|
const newlineIndex = value.indexOf('\n', index)
|
|
return newlineIndex === -1 ? value.length : newlineIndex
|
|
}
|
|
|
|
const transformSelectedLines = (transformer: (line: string, index: number) => string) => {
|
|
const value = form.content ?? ''
|
|
const { start, end } = getSelectionRange()
|
|
const { safeStart, safeEnd } = clampRange(start, end, value.length)
|
|
const lineStart = findLineStart(value, safeStart)
|
|
const lineEnd = findLineEnd(value, safeEnd)
|
|
const selected = value.slice(lineStart, lineEnd)
|
|
const lines = selected.split('\n')
|
|
const transformed = lines.map((line, index) => transformer(line, index))
|
|
const result = transformed.join('\n')
|
|
const newContent = value.slice(0, lineStart) + result + value.slice(lineEnd)
|
|
updateContentWithSelection(newContent, lineStart, lineStart + result.length)
|
|
}
|
|
|
|
const wrapSelection = (prefix: string, suffix: string, placeholder: string) => {
|
|
const value = form.content ?? ''
|
|
const { start, end } = getSelectionRange()
|
|
const { safeStart, safeEnd } = clampRange(start, end, value.length)
|
|
const hasSelection = safeEnd > safeStart
|
|
const selectedText = hasSelection ? value.slice(safeStart, safeEnd) : placeholder
|
|
const result =
|
|
value.slice(0, safeStart) + prefix + selectedText + suffix + value.slice(safeEnd)
|
|
const selectionStart = safeStart + prefix.length
|
|
const selectionEnd = selectionStart + selectedText.length
|
|
updateContentWithSelection(result, selectionStart, selectionEnd)
|
|
}
|
|
|
|
const insertBlock = (
|
|
text: string,
|
|
selectionStartOffset?: number,
|
|
selectionEndOffset?: number,
|
|
) => {
|
|
const value = form.content ?? ''
|
|
const { start, end } = getSelectionRange()
|
|
const { safeStart, safeEnd } = clampRange(start, end, value.length)
|
|
const before = value.slice(0, safeStart)
|
|
const after = value.slice(safeEnd)
|
|
const result = before + text + after
|
|
const base = safeStart
|
|
const selectionStart =
|
|
selectionStartOffset !== undefined ? base + selectionStartOffset : base + text.length
|
|
const selectionEnd =
|
|
selectionEndOffset !== undefined ? base + selectionEndOffset : selectionStart
|
|
updateContentWithSelection(result, selectionStart, selectionEnd)
|
|
}
|
|
|
|
const applyHeading = (level: number) => {
|
|
const hashes = '#'.repeat(level)
|
|
transformSelectedLines((line) => {
|
|
const trimmed = line.replace(/^#+\s*/, '').trim()
|
|
const content = trimmed || t('manualEditor.placeholders.heading', { level })
|
|
return `${hashes} ${content}`
|
|
})
|
|
}
|
|
|
|
const listPrefixPattern =
|
|
/^(\s*(?:[-*+]|\d+\.)\s+|\s*-\s+\[[ xX]\]\s+)/
|
|
|
|
const applyBulletList = () => {
|
|
transformSelectedLines((line) => {
|
|
const trimmed = line.trim()
|
|
const content = trimmed.replace(listPrefixPattern, '').trim()
|
|
return `- ${content || t('manualEditor.placeholders.listItem')}`
|
|
})
|
|
}
|
|
|
|
const applyOrderedList = () => {
|
|
transformSelectedLines((line, index) => {
|
|
const trimmed = line.trim()
|
|
const content = trimmed.replace(listPrefixPattern, '').trim()
|
|
return `${index + 1}. ${content || t('manualEditor.placeholders.listItem')}`
|
|
})
|
|
}
|
|
|
|
const applyTaskList = () => {
|
|
transformSelectedLines((line) => {
|
|
const trimmed = line.trim()
|
|
const content = trimmed.replace(listPrefixPattern, '').trim()
|
|
return `- [ ] ${content || t('manualEditor.placeholders.taskItem')}`
|
|
})
|
|
}
|
|
|
|
const applyBlockquote = () => {
|
|
transformSelectedLines((line) => {
|
|
const trimmed = line.trim().replace(/^>\s?/, '').trim()
|
|
return `> ${trimmed || t('manualEditor.placeholders.quote')}`
|
|
})
|
|
}
|
|
|
|
const insertCodeBlock = () => {
|
|
const placeholder = t('manualEditor.placeholders.code')
|
|
const block = `\n\`\`\`\n${placeholder}\n\`\`\`\n`
|
|
const startOffset = block.indexOf(placeholder)
|
|
insertBlock(block, startOffset, startOffset + placeholder.length)
|
|
}
|
|
|
|
const insertHorizontalRule = () => {
|
|
insertBlock('\n---\n\n')
|
|
}
|
|
|
|
const insertTable = () => {
|
|
const cell = t('manualEditor.table.cell')
|
|
const template = `\n| ${t('manualEditor.table.column1')} | ${t('manualEditor.table.column2')} |\n| --- | --- |\n| ${cell} | ${cell} |\n`
|
|
const placeholderIndex = template.indexOf(cell)
|
|
insertBlock(template, placeholderIndex, placeholderIndex + cell.length)
|
|
}
|
|
|
|
const insertLink = () => {
|
|
const value = form.content ?? ''
|
|
const { start, end } = getSelectionRange()
|
|
const { safeStart, safeEnd } = clampRange(start, end, value.length)
|
|
const selectedText =
|
|
safeEnd > safeStart ? value.slice(safeStart, safeEnd) : t('manualEditor.placeholders.linkText')
|
|
const urlPlaceholder = 'https://'
|
|
const result =
|
|
value.slice(0, safeStart) +
|
|
`[${selectedText}](${urlPlaceholder})` +
|
|
value.slice(safeEnd)
|
|
const urlStart = safeStart + selectedText.length + 3
|
|
const urlEnd = urlStart + urlPlaceholder.length
|
|
updateContentWithSelection(result, urlStart, urlEnd)
|
|
}
|
|
|
|
const insertImage = () => {
|
|
const value = form.content ?? ''
|
|
const { start, end } = getSelectionRange()
|
|
const { safeStart, safeEnd } = clampRange(start, end, value.length)
|
|
const altText = safeEnd > safeStart ? value.slice(safeStart, safeEnd) : t('manualEditor.placeholders.imageAlt')
|
|
const urlPlaceholder = 'https://'
|
|
const result =
|
|
value.slice(0, safeStart) +
|
|
`` +
|
|
value.slice(safeEnd)
|
|
const urlStart = safeStart + altText.length + 4
|
|
const urlEnd = urlStart + urlPlaceholder.length
|
|
updateContentWithSelection(result, urlStart, urlEnd)
|
|
}
|
|
|
|
type ToolbarAction = () => void
|
|
type ToolbarButton = {
|
|
key: string
|
|
tooltip: string
|
|
action: ToolbarAction
|
|
icon: string
|
|
}
|
|
type ToolbarGroup = {
|
|
key: string
|
|
buttons: ToolbarButton[]
|
|
}
|
|
|
|
const toolbarGroups = computed<ToolbarGroup[]>(() => [
|
|
{
|
|
key: 'format',
|
|
buttons: [
|
|
{ key: 'bold', icon: 'textformat-bold', tooltip: t('manualEditor.toolbar.bold'), action: () => wrapSelection('**', '**', t('manualEditor.placeholders.bold')) },
|
|
{ key: 'italic', icon: 'textformat-italic', tooltip: t('manualEditor.toolbar.italic'), action: () => wrapSelection('*', '*', t('manualEditor.placeholders.italic')) },
|
|
{ key: 'strike', icon: 'textformat-strikethrough', tooltip: t('manualEditor.toolbar.strike'), action: () => wrapSelection('~~', '~~', t('manualEditor.placeholders.strike')) },
|
|
{ key: 'inline-code', icon: 'code', tooltip: t('manualEditor.toolbar.inlineCode'), action: () => wrapSelection('`', '`', t('manualEditor.placeholders.inlineCode')) },
|
|
],
|
|
},
|
|
{
|
|
key: 'heading',
|
|
buttons: [
|
|
{ key: 'h1', icon: 'numbers-1', tooltip: t('manualEditor.toolbar.heading1'), action: () => applyHeading(1) },
|
|
{ key: 'h2', icon: 'numbers-2', tooltip: t('manualEditor.toolbar.heading2'), action: () => applyHeading(2) },
|
|
{ key: 'h3', icon: 'numbers-3', tooltip: t('manualEditor.toolbar.heading3'), action: () => applyHeading(3) },
|
|
],
|
|
},
|
|
{
|
|
key: 'list',
|
|
buttons: [
|
|
{ key: 'ul', icon: 'view-list', tooltip: t('manualEditor.toolbar.bulletList'), action: applyBulletList },
|
|
{ key: 'ol', icon: 'list-numbered', tooltip: t('manualEditor.toolbar.orderedList'), action: applyOrderedList },
|
|
{ key: 'task', icon: 'check-rectangle', tooltip: t('manualEditor.toolbar.taskList'), action: applyTaskList },
|
|
{ key: 'quote', icon: 'quote', tooltip: t('manualEditor.toolbar.blockquote'), action: applyBlockquote },
|
|
],
|
|
},
|
|
{
|
|
key: 'insert',
|
|
buttons: [
|
|
{ key: 'codeblock', icon: 'code-1', tooltip: t('manualEditor.toolbar.codeBlock'), action: insertCodeBlock },
|
|
{ key: 'link', icon: 'link', tooltip: t('manualEditor.toolbar.link'), action: insertLink },
|
|
{ key: 'image', icon: 'image', tooltip: t('manualEditor.toolbar.image'), action: insertImage },
|
|
{ key: 'table', icon: 'table', tooltip: t('manualEditor.toolbar.table'), action: insertTable },
|
|
{ key: 'hr', icon: 'component-divider-horizontal', tooltip: t('manualEditor.toolbar.horizontalRule'), action: insertHorizontalRule },
|
|
],
|
|
},
|
|
])
|
|
|
|
const isPreviewMode = computed(() => activeTab.value === 'preview')
|
|
const viewToggleIcon = computed(() => (isPreviewMode.value ? 'edit' : 'view-module'))
|
|
const viewToggleLabel = computed(() =>
|
|
isPreviewMode.value ? t('manualEditor.view.editLabel') : t('manualEditor.view.previewLabel'),
|
|
)
|
|
|
|
const handleToolbarAction = (action: ToolbarAction) => {
|
|
if (saving.value) {
|
|
return
|
|
}
|
|
if (activeTab.value !== 'edit') {
|
|
activeTab.value = 'edit'
|
|
nextTick(() => {
|
|
attachTextareaListeners()
|
|
action()
|
|
})
|
|
} else {
|
|
attachTextareaListeners()
|
|
action()
|
|
}
|
|
}
|
|
|
|
const toggleEditorView = () => {
|
|
activeTab.value = isPreviewMode.value ? 'edit' : 'preview'
|
|
}
|
|
|
|
marked.use({})
|
|
|
|
const previewHTML = computed(() => {
|
|
if (!form.content) {
|
|
return `<p class="empty-preview">${t('manualEditor.preview.empty')}</p>`
|
|
}
|
|
const safeMarkdown = safeMarkdownToHTML(form.content)
|
|
const html = marked.parse(safeMarkdown, { async: false })
|
|
return sanitizeHTML(html)
|
|
})
|
|
|
|
const kbDisabled = computed(() => mode.value === 'edit' && !!form.kbId)
|
|
|
|
const dialogTitle = computed(() =>
|
|
mode.value === 'edit' ? t('manualEditor.title.edit') : t('manualEditor.title.create'),
|
|
)
|
|
|
|
const lastUpdatedText = computed(() =>
|
|
lastUpdatedAt.value ? t('manualEditor.status.lastUpdated', { time: lastUpdatedAt.value }) : '',
|
|
)
|
|
|
|
const loadKnowledgeBases = async () => {
|
|
kbLoading.value = true
|
|
try {
|
|
const [ownRes, sharedKbs] = await Promise.all([
|
|
listKnowledgeBases() as Promise<any>,
|
|
organizationStore.fetchSharedKnowledgeBases().catch(() => []),
|
|
])
|
|
|
|
const isDocumentKb = (type?: string) => !type || type === 'document'
|
|
|
|
const ownKbs = Array.isArray(ownRes?.data) ? ownRes.data : []
|
|
const list: KnowledgeBaseOption[] = ownKbs
|
|
.filter((item: any) => isDocumentKb(item.type))
|
|
.map((item: any) => ({ label: item.name, value: item.id }))
|
|
|
|
// Knowledge bases shared to the user with write access (editor/admin)
|
|
// also accept manually-added content, so they must appear in the picker;
|
|
// viewer-only shares are excluded since the backend would reject writes.
|
|
const seen = new Set(list.map((o) => o.value))
|
|
for (const share of sharedKbs) {
|
|
const kb = share?.knowledge_base
|
|
const canWrite = share?.permission === 'editor' || share?.permission === 'admin'
|
|
if (!kb || !canWrite || !isDocumentKb(kb.type) || seen.has(kb.id)) continue
|
|
seen.add(kb.id)
|
|
list.push({ label: kb.name, value: kb.id })
|
|
}
|
|
|
|
kbOptions.value = list
|
|
|
|
if (mode.value === 'create') {
|
|
const presetKbId = uiStore.manualEditorKBId
|
|
if (presetKbId) {
|
|
const exists = list.find((item) => item.value === presetKbId)
|
|
if (!exists) {
|
|
kbOptions.value.unshift({
|
|
label: t('manualEditor.labels.currentKnowledgeBase'),
|
|
value: presetKbId,
|
|
})
|
|
}
|
|
form.kbId = presetKbId
|
|
} else {
|
|
form.kbId = list[0]?.value ?? ''
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[ManualEditor] Failed to load knowledge base list:', error)
|
|
kbOptions.value = []
|
|
} finally {
|
|
kbLoading.value = false
|
|
}
|
|
}
|
|
|
|
const parseManualMetadata = (
|
|
metadata: any,
|
|
): { content: string; status: ManualStatus; updatedAt?: string } | null => {
|
|
if (!metadata) {
|
|
return null
|
|
}
|
|
try {
|
|
let parsed = metadata
|
|
if (typeof metadata === 'string') {
|
|
parsed = JSON.parse(metadata)
|
|
}
|
|
if (parsed && typeof parsed === 'object') {
|
|
const status = parsed.status === 'publish' ? 'publish' : 'draft'
|
|
return {
|
|
content: parsed.content || '',
|
|
status,
|
|
updatedAt: parsed.updated_at || parsed.updatedAt,
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('[ManualEditor] Failed to parse manual metadata:', error)
|
|
}
|
|
return null
|
|
}
|
|
|
|
const loadKnowledgeContent = async () => {
|
|
if (!knowledgeId.value) {
|
|
return
|
|
}
|
|
contentLoading.value = true
|
|
try {
|
|
const res: any = await getKnowledgeDetails(knowledgeId.value)
|
|
const data: KnowledgeDetailResponse | undefined = res?.data
|
|
if (!data) {
|
|
MessagePlugin.error(t('manualEditor.error.fetchDetailFailed'))
|
|
return
|
|
}
|
|
|
|
form.kbId = data.knowledge_base_id || form.kbId
|
|
const meta = parseManualMetadata(data.metadata)
|
|
form.title =
|
|
data.title ||
|
|
data.file_name?.replace(/\.md$/i, '') ||
|
|
uiStore.manualEditorInitialTitle ||
|
|
''
|
|
form.content = meta?.content || uiStore.manualEditorInitialContent || ''
|
|
form.status = resolveManualKnowledgeStatus(meta?.status, data.parse_status)
|
|
if (meta?.updatedAt) {
|
|
lastUpdatedAt.value = meta.updatedAt
|
|
}
|
|
|
|
if (form.kbId && !kbOptions.value.find((item) => item.value === form.kbId)) {
|
|
kbOptions.value.unshift({
|
|
label: t('manualEditor.labels.currentKnowledgeBase'),
|
|
value: form.kbId,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error('[ManualEditor] Failed to load manual knowledge:', error)
|
|
MessagePlugin.error(t('manualEditor.error.fetchDetailFailed'))
|
|
} finally {
|
|
contentLoading.value = false
|
|
}
|
|
}
|
|
|
|
const resetForm = () => {
|
|
form.kbId = uiStore.manualEditorKBId || ''
|
|
form.title = uiStore.manualEditorInitialTitle || ''
|
|
form.content = uiStore.manualEditorInitialContent || ''
|
|
form.status = uiStore.manualEditorInitialStatus || 'draft'
|
|
activeTab.value = 'edit'
|
|
lastUpdatedAt.value = ''
|
|
initialLoaded.value = false
|
|
selectionRange.start = 0
|
|
selectionRange.end = 0
|
|
}
|
|
|
|
const generateDefaultTitle = () => {
|
|
if (uiStore.manualEditorInitialTitle) {
|
|
return uiStore.manualEditorInitialTitle
|
|
}
|
|
return `${t('manualEditor.defaultTitlePrefix')}-${new Date().toLocaleString()}`
|
|
}
|
|
|
|
const initialize = async () => {
|
|
resetForm()
|
|
await loadKnowledgeBases()
|
|
|
|
if (mode.value === 'edit') {
|
|
await loadKnowledgeContent()
|
|
} else {
|
|
const presetKbId = uiStore.manualEditorKBId
|
|
if (presetKbId) {
|
|
form.kbId = presetKbId
|
|
} else if (!form.kbId && kbOptions.value.length) {
|
|
form.kbId = kbOptions.value[0].value
|
|
}
|
|
form.title = form.title || generateDefaultTitle()
|
|
form.content = form.content || ''
|
|
}
|
|
|
|
initialLoaded.value = true
|
|
}
|
|
|
|
const validateForm = (targetStatus: ManualStatus): boolean => {
|
|
if (!form.kbId) {
|
|
MessagePlugin.warning(t('manualEditor.warning.selectKnowledgeBase'))
|
|
return false
|
|
}
|
|
if (!form.title || !form.title.trim()) {
|
|
MessagePlugin.warning(t('manualEditor.warning.enterTitle'))
|
|
return false
|
|
}
|
|
if (!form.content || !form.content.trim()) {
|
|
MessagePlugin.warning(t('manualEditor.warning.enterContent'))
|
|
return false
|
|
}
|
|
if (targetStatus === 'publish' && form.content.trim().length < 10) {
|
|
MessagePlugin.warning(t('manualEditor.warning.contentTooShort'))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const handleSave = async (targetStatus: ManualStatus) => {
|
|
if (saving.value || !validateForm(targetStatus)) {
|
|
return
|
|
}
|
|
saving.value = true
|
|
savingAction.value = targetStatus
|
|
try {
|
|
const tagIdsToUpload = uiStore.selectedTagIds.length > 0 ? [...uiStore.selectedTagIds] : undefined
|
|
const payload: {
|
|
title: string
|
|
content: string
|
|
status: string
|
|
tag_ids?: string[]
|
|
process_config?: KnowledgeProcessOverrides
|
|
} = {
|
|
title: form.title.trim(),
|
|
content: form.content,
|
|
status: targetStatus,
|
|
}
|
|
if (tagIdsToUpload && tagIdsToUpload.length > 0) {
|
|
payload.tag_ids = tagIdsToUpload
|
|
}
|
|
|
|
if (targetStatus === 'publish') {
|
|
let kbInfo: any
|
|
try {
|
|
const kbRes: any = await getKnowledgeBaseById(form.kbId)
|
|
kbInfo = kbRes?.data
|
|
} catch {
|
|
MessagePlugin.error(t('manualEditor.error.fetchDetailFailed'))
|
|
return
|
|
}
|
|
if (!kbInfo) {
|
|
MessagePlugin.error(t('manualEditor.error.fetchDetailFailed'))
|
|
return
|
|
}
|
|
try {
|
|
const confirmResult = await uploadConfirmStore.open({
|
|
mode: 'manual',
|
|
kbInfo,
|
|
manual: {
|
|
kbId: form.kbId,
|
|
knowledgeId: knowledgeId.value || undefined,
|
|
title: payload.title,
|
|
content: payload.content,
|
|
tagIds: tagIdsToUpload,
|
|
},
|
|
})
|
|
payload.process_config = confirmResult.processConfig
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
|
|
let response: any
|
|
let knowledgeID = knowledgeId.value
|
|
let kbId = form.kbId
|
|
|
|
if (mode.value === 'edit' && knowledgeId.value) {
|
|
response = await updateManualKnowledge(knowledgeId.value, payload)
|
|
} else {
|
|
response = await createManualKnowledge(form.kbId, payload)
|
|
knowledgeID = response?.data?.id || knowledgeID
|
|
kbId = form.kbId
|
|
}
|
|
|
|
if (response?.success) {
|
|
MessagePlugin.success(
|
|
targetStatus === 'draft'
|
|
? t('manualEditor.success.draftSaved')
|
|
: t('manualEditor.success.published'),
|
|
)
|
|
if (knowledgeID) {
|
|
uiStore.notifyManualEditorSuccess({
|
|
kbId,
|
|
knowledgeId: knowledgeID,
|
|
status: targetStatus,
|
|
})
|
|
}
|
|
uiStore.closeManualEditor()
|
|
} else {
|
|
const message = response?.message || t('manualEditor.error.saveFailed')
|
|
MessagePlugin.error(message)
|
|
}
|
|
} catch (error: any) {
|
|
const message = error?.error?.message || error?.message || t('manualEditor.error.saveFailed')
|
|
MessagePlugin.error(message)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
uiStore.closeManualEditor()
|
|
}
|
|
|
|
watch(visible, async (val) => {
|
|
if (val) {
|
|
await nextTick()
|
|
await initialize()
|
|
await nextTick()
|
|
attachTextareaListeners()
|
|
const length = form.content ? form.content.length : 0
|
|
setSelectionRange(length, length)
|
|
} else {
|
|
detachTextareaListeners()
|
|
resetForm()
|
|
}
|
|
})
|
|
|
|
watch(activeTab, (val) => {
|
|
if (val === 'edit') {
|
|
nextTick(() => {
|
|
attachTextareaListeners()
|
|
})
|
|
} else {
|
|
detachTextareaListeners()
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
detachTextareaListeners()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<SettingDrawer
|
|
:visible="visible"
|
|
:title="dialogTitle"
|
|
:description="$t('manualEditor.description')"
|
|
icon="edit-1"
|
|
width="760px"
|
|
:min-width="560"
|
|
:max-width="1280"
|
|
storage-key="setting-drawer:width:manual-markdown-editor"
|
|
:hide-footer="!initialLoaded"
|
|
:confirm-loading="saving && savingAction === 'publish'"
|
|
:confirm-text="$t('manualEditor.actions.publish')"
|
|
:cancel-text="$t('manualEditor.actions.cancel')"
|
|
@update:visible="(v: boolean) => { visible = v }"
|
|
@confirm="handleSave('publish')"
|
|
>
|
|
<template #footer-left>
|
|
<t-button
|
|
variant="text"
|
|
theme="primary"
|
|
class="toggle-view-btn"
|
|
@click="toggleEditorView"
|
|
>
|
|
<template #icon><t-icon :name="viewToggleIcon" /></template>
|
|
{{ viewToggleLabel }}
|
|
</t-button>
|
|
<t-button
|
|
variant="outline"
|
|
theme="default"
|
|
@click="handleSave('draft')"
|
|
:loading="saving && savingAction === 'draft'"
|
|
>
|
|
{{ $t('manualEditor.actions.saveDraft') }}
|
|
</t-button>
|
|
</template>
|
|
|
|
<div class="manual-editor" v-if="initialLoaded">
|
|
<section class="setting-drawer__section">
|
|
<h4 class="setting-drawer__section-title">{{ $t('manualEditor.section.basic') }}</h4>
|
|
|
|
<div class="form-item">
|
|
<label class="form-label required">{{ $t('manualEditor.form.titleLabel') }}</label>
|
|
<t-input
|
|
v-model="form.title"
|
|
maxlength="100"
|
|
:placeholder="$t('manualEditor.form.titlePlaceholder')"
|
|
showLimitNumber
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-item">
|
|
<label class="form-label required">{{ $t('manualEditor.form.knowledgeBaseLabel') }}</label>
|
|
<div class="kb-row">
|
|
<t-select
|
|
v-model="form.kbId"
|
|
:disabled="kbDisabled"
|
|
:loading="kbLoading"
|
|
:options="kbOptions"
|
|
:placeholder="$t('manualEditor.form.knowledgeBasePlaceholder')"
|
|
:popup-props="{ attach: 'body', zIndex: 2600 }"
|
|
>
|
|
<template #empty>
|
|
<div style="padding: 20px; text-align: center; color: var(--td-text-color-placeholder);">
|
|
{{ $t('manualEditor.noDocumentKnowledgeBases') }}
|
|
</div>
|
|
</template>
|
|
</t-select>
|
|
<div class="status-row" v-if="mode === 'edit'">
|
|
<t-tag size="small" theme="warning" variant="light" v-if="form.status === 'draft'">
|
|
{{ $t('manualEditor.status.draftTag') }}
|
|
</t-tag>
|
|
<t-tag size="small" theme="success" variant="light" v-else>
|
|
{{ $t('manualEditor.status.publishedTag') }}
|
|
</t-tag>
|
|
</div>
|
|
</div>
|
|
<p v-if="lastUpdatedText" class="form-desc">{{ lastUpdatedText }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="setting-drawer__section editor-section">
|
|
<h4 class="setting-drawer__section-title">{{ $t('manualEditor.section.content') }}</h4>
|
|
|
|
<div class="editor-area">
|
|
<div class="editor-toolbar">
|
|
<template v-for="(group, groupIndex) in toolbarGroups" :key="group.key">
|
|
<div class="toolbar-group">
|
|
<template v-for="btn in group.buttons" :key="btn.key">
|
|
<t-tooltip :content="btn.tooltip" placement="top">
|
|
<button
|
|
type="button"
|
|
class="toolbar-btn"
|
|
:class="`btn-${btn.key}`"
|
|
@mousedown.prevent
|
|
@click="handleToolbarAction(btn.action)"
|
|
>
|
|
<t-icon :name="btn.icon" size="18px" />
|
|
</button>
|
|
</t-tooltip>
|
|
</template>
|
|
</div>
|
|
<div
|
|
v-if="groupIndex < toolbarGroups.length - 1"
|
|
class="toolbar-divider"
|
|
></div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="editor-pane" v-show="activeTab === 'edit'">
|
|
<t-textarea
|
|
ref="textareaComponent"
|
|
v-if="!contentLoading"
|
|
v-model="form.content"
|
|
:placeholder="$t('manualEditor.form.contentPlaceholder')"
|
|
class="editor-textarea"
|
|
/>
|
|
<div v-else class="loading-placeholder">
|
|
<t-loading size="small" :text="$t('manualEditor.loading.content')" />
|
|
</div>
|
|
</div>
|
|
<div class="editor-pane editor-pane--preview" v-show="activeTab === 'preview'">
|
|
<div class="preview-container" v-html="previewHTML" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<div v-else class="loading-wrapper">
|
|
<t-loading size="medium" :text="$t('manualEditor.loading.preparing')" />
|
|
</div>
|
|
</SettingDrawer>
|
|
</template>
|
|
|
|
<style scoped lang="less">
|
|
/* 复用模型管理同款 SettingDrawer:分组 section / header 图标 / footer 按钮 / 拖拽调宽。
|
|
这里只负责本编辑器特有的内容样式。内容内联渲染(无 teleport),scoped 生效。 */
|
|
.manual-editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.form-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.form-label {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--td-text-color-primary);
|
|
|
|
&.required::after {
|
|
content: '*';
|
|
margin-left: 4px;
|
|
color: var(--td-error-color);
|
|
}
|
|
}
|
|
|
|
.form-desc {
|
|
margin: 2px 0 0;
|
|
font-size: 12px;
|
|
color: var(--td-text-color-placeholder);
|
|
}
|
|
|
|
.kb-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
|
|
:deep(.t-select) {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
}
|
|
|
|
.status-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-shrink: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* 内容分组:让编辑区占满,无需依赖父级 flex 链路,直接用视口高度,稳健 */
|
|
.editor-section {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.editor-toolbar {
|
|
display: flex;
|
|
flex-wrap: nowrap;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 8px;
|
|
background: var(--td-bg-color-secondarycontainer);
|
|
border-bottom: 1px solid var(--td-component-stroke);
|
|
overflow-x: auto;
|
|
flex-shrink: 0;
|
|
|
|
&::-webkit-scrollbar {
|
|
height: 0;
|
|
}
|
|
}
|
|
|
|
.toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.toolbar-divider {
|
|
width: 1px;
|
|
height: 18px;
|
|
background: var(--td-component-stroke);
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.toolbar-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
padding: 0;
|
|
border-radius: 6px;
|
|
color: var(--td-text-color-secondary);
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
.t-icon {
|
|
color: var(--td-text-color-secondary);
|
|
font-size: 16px;
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
}
|
|
|
|
.toolbar-btn:hover {
|
|
background: rgba(7, 192, 95, 0.08);
|
|
color: var(--td-brand-color);
|
|
|
|
.t-icon {
|
|
color: var(--td-brand-color);
|
|
}
|
|
}
|
|
|
|
.toolbar-btn.active {
|
|
background: rgba(7, 192, 95, 0.12);
|
|
color: var(--td-brand-color);
|
|
|
|
.t-icon {
|
|
color: var(--td-brand-color);
|
|
}
|
|
}
|
|
|
|
.toolbar-btn:focus-visible {
|
|
outline: none;
|
|
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.25);
|
|
}
|
|
|
|
.toolbar-btn:active {
|
|
background: rgba(7, 192, 95, 0.15);
|
|
transform: translateY(0.5px);
|
|
}
|
|
|
|
.editor-area {
|
|
/* 抽屉为整屏高,减去 header/footer/基本信息分组的大致高度,
|
|
让编辑区占据剩余空间且不必撑满父级 flex 链路。 */
|
|
height: calc(100vh - 360px);
|
|
min-height: 280px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid var(--td-component-stroke);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background: var(--td-bg-color-container);
|
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
|
|
&:focus-within {
|
|
border-color: var(--td-brand-color);
|
|
box-shadow: 0 0 0 2px rgba(7, 192, 95, 0.1);
|
|
}
|
|
}
|
|
|
|
.editor-pane {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
background: var(--td-bg-color-container);
|
|
}
|
|
|
|
:deep(.editor-textarea) {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
|
|
.t-textarea__inner {
|
|
flex: 1;
|
|
height: 100% !important;
|
|
resize: none;
|
|
border: none;
|
|
border-radius: 0;
|
|
padding: 14px 16px;
|
|
font-family: var(--app-font-family-mono);
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
background: var(--td-bg-color-container);
|
|
|
|
&:focus {
|
|
box-shadow: none;
|
|
}
|
|
}
|
|
}
|
|
|
|
.editor-pane--preview {
|
|
background: var(--td-bg-color-container);
|
|
}
|
|
|
|
.preview-container {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
background: var(--td-bg-color-container);
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
color: var(--td-text-color-primary);
|
|
|
|
:deep(h1),
|
|
:deep(h2),
|
|
:deep(h3),
|
|
:deep(h4) {
|
|
margin-top: 16px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
:deep(code) {
|
|
background: var(--td-bg-color-container-hover);
|
|
padding: 2px 4px;
|
|
border-radius: 4px;
|
|
font-family: var(--app-font-family-mono);
|
|
}
|
|
|
|
:deep(pre) {
|
|
background: var(--td-bg-color-container-hover);
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
overflow: auto;
|
|
}
|
|
|
|
:deep(blockquote) {
|
|
border-left: 4px solid var(--td-brand-color);
|
|
padding-left: 12px;
|
|
color: var(--td-text-color-secondary);
|
|
margin: 16px 0;
|
|
background: rgba(7, 192, 95, 0.08);
|
|
}
|
|
|
|
:deep(a) {
|
|
color: var(--td-brand-color);
|
|
}
|
|
}
|
|
|
|
.loading-wrapper,
|
|
.loading-placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
min-height: 280px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.empty-preview {
|
|
color: var(--td-text-color-placeholder);
|
|
}
|
|
</style>
|