cec722aa43
- Introduced tag_id parameter in various knowledge-related API functions to allow categorization of knowledge entries. - Updated frontend components to handle tag_id during knowledge creation and file uploads. - Enhanced UI store to manage the selected tag ID for better user experience in categorizing knowledge.
1059 lines
29 KiB
Vue
1059 lines
29 KiB
Vue
<script setup lang="ts">
|
|
import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
|
import { marked } from 'marked'
|
|
import { MessagePlugin } from 'tdesign-vue-next'
|
|
import { useUIStore } from '@/stores/ui'
|
|
import { listKnowledgeBases, getKnowledgeDetails, createManualKnowledge, updateManualKnowledge } from '@/api/knowledge-base'
|
|
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'
|
|
|
|
const uiStore = useUIStore()
|
|
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
|
|
}
|
|
textarea.focus()
|
|
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 viewToggleTooltip = computed(() =>
|
|
isPreviewMode.value
|
|
? t('manualEditor.view.toggleToEdit')
|
|
: t('manualEditor.view.toggleToPreview'),
|
|
)
|
|
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({
|
|
mangle: false,
|
|
headerIds: false,
|
|
})
|
|
|
|
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)
|
|
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 res: any = await listKnowledgeBases()
|
|
console.log('[ManualEditor] Raw knowledge bases response:', res?.data)
|
|
|
|
const allKbs = Array.isArray(res?.data) ? res.data : []
|
|
console.log('[ManualEditor] All knowledge bases:', allKbs)
|
|
console.log('[ManualEditor] KB types:', allKbs.map((kb: any) => ({ name: kb.name, type: kb.type })))
|
|
|
|
const list: KnowledgeBaseOption[] = allKbs
|
|
.filter((item: any) => {
|
|
const isDocument = !item.type || item.type === 'document'
|
|
console.log(`[ManualEditor] KB "${item.name}" (type: ${item.type}): ${isDocument ? 'INCLUDED' : 'FILTERED OUT'}`)
|
|
return isDocument
|
|
})
|
|
.map((item: any) => ({ label: item.name, value: item.id }))
|
|
|
|
console.log('[ManualEditor] Filtered knowledge bases:', list)
|
|
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 ?? ''
|
|
}
|
|
}
|
|
|
|
console.log('[ManualEditor] Final kbOptions:', kbOptions.value)
|
|
console.log('[ManualEditor] Selected kbId:', form.kbId)
|
|
} 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 = meta?.status || (data.parse_status === 'completed' ? 'publish' : 'draft')
|
|
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 payload: { title: string; content: string; status: string; tag_id?: string } = {
|
|
title: form.title.trim(),
|
|
content: form.content,
|
|
status: targetStatus,
|
|
}
|
|
let response: any
|
|
let knowledgeID = knowledgeId.value
|
|
let kbId = form.kbId
|
|
|
|
if (mode.value === 'edit' && knowledgeId.value) {
|
|
response = await updateManualKnowledge(knowledgeId.value, payload)
|
|
} else {
|
|
// 创建新知识时,从 store 获取当前选中的分类ID
|
|
const tagIdToUpload = uiStore.selectedTagId !== '__untagged__' ? uiStore.selectedTagId : undefined
|
|
if (tagIdToUpload) {
|
|
payload.tag_id = tagIdToUpload
|
|
}
|
|
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>
|
|
<t-dialog
|
|
v-model:visible="visible"
|
|
:header="dialogTitle"
|
|
:closeBtn="true"
|
|
:footer="false"
|
|
width="880px"
|
|
top="5%"
|
|
class="manual-knowledge-editor"
|
|
destroy-on-close
|
|
>
|
|
<div class="editor-body" v-if="initialLoaded">
|
|
<div class="form-row">
|
|
<label class="form-label">{{ $t('manualEditor.form.knowledgeBaseLabel') }}</label>
|
|
<t-select
|
|
v-model="form.kbId"
|
|
:disabled="kbDisabled"
|
|
:loading="kbLoading"
|
|
:options="kbOptions"
|
|
:placeholder="$t('manualEditor.form.knowledgeBasePlaceholder')"
|
|
:popup-props="{ attach: 'body' }"
|
|
>
|
|
<template #empty>
|
|
<div style="padding: 20px; text-align: center; color: #999;">
|
|
{{ $t('manualEditor.noDocumentKnowledgeBases') || '暂无可用的文档型知识库,请先创建一个文档型知识库' }}
|
|
</div>
|
|
</template>
|
|
</t-select>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<label class="form-label">{{ $t('manualEditor.form.titleLabel') }}</label>
|
|
<t-input
|
|
v-model="form.title"
|
|
maxlength="100"
|
|
:placeholder="$t('manualEditor.form.titlePlaceholder')"
|
|
showLimitNumber
|
|
/>
|
|
</div>
|
|
|
|
<div class="status-row" v-if="mode === 'edit'">
|
|
<t-tag theme="warning" v-if="form.status === 'draft'">{{ $t('manualEditor.status.draftTag') }}</t-tag>
|
|
<t-tag theme="success" v-else>{{ $t('manualEditor.status.publishedTag') }}</t-tag>
|
|
<span v-if="lastUpdatedText" class="status-timestamp">{{ lastUpdatedText }}</span>
|
|
</div>
|
|
|
|
<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-area">
|
|
<div class="editor-pane" v-show="activeTab === 'edit'">
|
|
<t-textarea
|
|
ref="textareaComponent"
|
|
v-if="!contentLoading"
|
|
v-model="form.content"
|
|
:placeholder="$t('manualEditor.form.contentPlaceholder')"
|
|
:autosize="{ minRows: 16, maxRows: 24 }"
|
|
/>
|
|
<div v-else class="loading-placeholder">
|
|
<t-loading size="small" :text="$t('manualEditor.loading.content')" />
|
|
</div>
|
|
</div>
|
|
<div class="editor-pane" v-show="activeTab === 'preview'">
|
|
<div class="preview-container" v-html="previewHTML" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dialog-footer">
|
|
<div class="footer-left">
|
|
<t-button variant="outline" theme="default" @click="handleClose">
|
|
{{ $t('manualEditor.actions.cancel') }}
|
|
</t-button>
|
|
</div>
|
|
<div class="footer-right">
|
|
<t-tooltip :content="viewToggleTooltip" placement="top">
|
|
<t-button
|
|
variant="outline"
|
|
theme="default"
|
|
class="toggle-view-btn"
|
|
:class="{ active: isPreviewMode }"
|
|
@click="toggleEditorView"
|
|
>
|
|
<t-icon :name="viewToggleIcon" size="16px" />
|
|
<span>{{ viewToggleLabel }}</span>
|
|
</t-button>
|
|
</t-tooltip>
|
|
<t-button
|
|
variant="outline"
|
|
theme="default"
|
|
@click="handleSave('draft')"
|
|
:loading="saving && savingAction === 'draft'"
|
|
>
|
|
{{ $t('manualEditor.actions.saveDraft') }}
|
|
</t-button>
|
|
<t-button
|
|
theme="primary"
|
|
@click="handleSave('publish')"
|
|
:loading="saving && savingAction === 'publish'"
|
|
>
|
|
{{ $t('manualEditor.actions.publish') }}
|
|
</t-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="loading-wrapper">
|
|
<t-loading size="medium" :text="$t('manualEditor.loading.preparing')" />
|
|
</div>
|
|
</t-dialog>
|
|
</template>
|
|
|
|
<style scoped lang="less">
|
|
.manual-knowledge-editor {
|
|
:deep(.t-dialog__body) {
|
|
padding: 20px 24px 12px;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
}
|
|
}
|
|
|
|
.editor-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.form-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.form-label {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: #000000e6;
|
|
}
|
|
|
|
.status-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.editor-toolbar {
|
|
display: flex;
|
|
flex-wrap: nowrap;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
background: #ffffff;
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.toolbar-divider {
|
|
width: 1px;
|
|
height: 24px;
|
|
background: #e0e0e0;
|
|
margin: 0 2px;
|
|
}
|
|
|
|
.toolbar-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
padding: 0;
|
|
border-radius: 6px;
|
|
color: #666;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
.t-icon {
|
|
color: #666;
|
|
font-size: 16px;
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
}
|
|
|
|
.toolbar-btn:hover {
|
|
background: rgba(7, 192, 95, 0.08);
|
|
color: #07c05f;
|
|
|
|
.t-icon {
|
|
color: #07c05f;
|
|
}
|
|
}
|
|
|
|
.toolbar-btn.active {
|
|
background: rgba(7, 192, 95, 0.12);
|
|
color: #07c05f;
|
|
|
|
.t-icon {
|
|
color: #07c05f;
|
|
}
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
:deep(.toggle-view-btn) {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 0 16px;
|
|
height: 32px;
|
|
line-height: 32px;
|
|
transition: all 0.18s ease;
|
|
}
|
|
|
|
:deep(.toggle-view-btn .t-button__content) {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
:deep(.toggle-view-btn .t-button__text) {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
:deep(.toggle-view-btn .t-icon) {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
font-size: 16px;
|
|
width: 16px;
|
|
height: 16px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
:deep(.toggle-view-btn .t-icon svg) {
|
|
display: block;
|
|
width: 16px;
|
|
height: 16px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
:deep(.toggle-view-btn .t-button__text > span:not(.t-icon)) {
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
:deep(.toggle-view-btn.active),
|
|
:deep(.toggle-view-btn:hover) {
|
|
background: rgba(7, 192, 95, 0.12) !important;
|
|
color: #059669 !important;
|
|
border-color: rgba(7, 192, 95, 0.4) !important;
|
|
|
|
.t-icon {
|
|
color: #059669;
|
|
}
|
|
}
|
|
|
|
.status-timestamp {
|
|
font-size: 12px;
|
|
color: #00000066;
|
|
}
|
|
|
|
.editor-area {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.editor-pane {
|
|
padding: 0;
|
|
overflow: hidden;
|
|
background: #fff;
|
|
}
|
|
|
|
:deep(.t-textarea__inner) {
|
|
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.preview-container {
|
|
min-height: 300px;
|
|
max-height: 520px;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
background: #fafafa;
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
color: #222;
|
|
|
|
:deep(h1),
|
|
:deep(h2),
|
|
:deep(h3),
|
|
:deep(h4) {
|
|
margin-top: 16px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
:deep(code) {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
padding: 2px 4px;
|
|
border-radius: 4px;
|
|
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
|
}
|
|
|
|
:deep(pre) {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
padding: 12px;
|
|
border-radius: 6px;
|
|
overflow: auto;
|
|
}
|
|
|
|
:deep(blockquote) {
|
|
border-left: 4px solid #07c05f;
|
|
padding-left: 12px;
|
|
color: #555;
|
|
margin: 16px 0;
|
|
background: rgba(7, 192, 95, 0.08);
|
|
}
|
|
|
|
:deep(a) {
|
|
color: #07c05f;
|
|
}
|
|
}
|
|
|
|
.dialog-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.footer-right {
|
|
display: flex;
|
|
gap: 16px;
|
|
}
|
|
|
|
.loading-wrapper,
|
|
.loading-placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 240px;
|
|
}
|
|
|
|
.empty-preview {
|
|
color: #999;
|
|
}
|
|
</style>
|
|
|
|
<style lang="less">
|
|
// 全局样式:确保 select 下拉列表在 dialog 之上
|
|
.t-popup {
|
|
z-index: 2600 !important;
|
|
}
|
|
</style>
|
|
|
|
|