初始化
This commit is contained in:
49
src/views/kb/components/ChunkEditModal.vue
Normal file
49
src/views/kb/components/ChunkEditModal.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
// 分段编辑弹窗:标题/内容/关联问题(每行一个)
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { updateChunk } from '@/api/kb'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const props = defineProps({ chunk: { type: Object, default: null } })
|
||||
const emit = defineEmits(['saved'])
|
||||
const loading = ref(false)
|
||||
const form = reactive({ title: '', content: '', questions: '' })
|
||||
|
||||
// 打开时从 chunk 回填(related_questions 藏在 meta_json)
|
||||
watch(() => props.chunk, (c) => {
|
||||
if (!c) return
|
||||
form.title = c.title || ''
|
||||
form.content = c.content || ''
|
||||
let qs = []
|
||||
try { qs = JSON.parse(c.meta_json || '{}').related_questions || [] } catch { /* meta 解析失败当作无 */ }
|
||||
form.questions = qs.join('\n')
|
||||
}, { immediate: true })
|
||||
|
||||
async function submit() {
|
||||
if (!form.content.trim()) { message.warning('内容不能为空'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
await updateChunk(props.chunk.id, {
|
||||
title: form.title.trim(),
|
||||
content: form.content,
|
||||
related_questions: form.questions.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
})
|
||||
message.success('已保存')
|
||||
open.value = false
|
||||
emit('saved')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal v-model:open="open" :title="`编辑分段 #${chunk?.id || ''}`" width="640px" :confirm-loading="loading" @ok="submit">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="标题"><a-input v-model:value="form.title" :maxlength="100" /></a-form-item>
|
||||
<a-form-item label="内容" required><a-textarea v-model:value="form.content" :rows="8" class="mono !text-xs" /></a-form-item>
|
||||
<a-form-item label="关联问题(每行一个,会参与检索命中)">
|
||||
<a-textarea v-model:value="form.questions" :rows="3" placeholder="如:脾胃虚寒怎么调理?" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
124
src/views/kb/components/ChunkListDrawer.vue
Normal file
124
src/views/kb/components/ChunkListDrawer.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
// 分段列表抽屉(800px):
|
||||
// - 启用 Switch 即点即生效(单条 UpdateChunk)
|
||||
// - 多选 → 批量启用/禁用/删除(PUT /chunks/batch,事务)
|
||||
// - 行内编辑按钮开 ChunkEditModal
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { EditOutlined } from '@ant-design/icons-vue'
|
||||
import { getChunks, updateChunk, batchChunks } from '@/api/kb'
|
||||
import ChunkEditModal from './ChunkEditModal.vue'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const props = defineProps({ doc: { type: Object, default: null } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const chunks = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedIds = ref([])
|
||||
const editOpen = ref(false)
|
||||
const editingChunk = ref(null)
|
||||
const switching = ref(0) // 正在切换的 chunk id(防止重复点)
|
||||
|
||||
async function load() {
|
||||
if (!props.doc?.id) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getChunks(props.doc.id)
|
||||
chunks.value = res.data || []
|
||||
selectedIds.value = []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
watch([open, () => props.doc?.id], ([isOpen]) => { if (isOpen) load() })
|
||||
|
||||
// 单条启用/禁用(即点即生效)
|
||||
async function toggleActive(chunk, checked) {
|
||||
switching.value = chunk.id
|
||||
try {
|
||||
await updateChunk(chunk.id, { is_active: checked ? 1 : 0 })
|
||||
chunk.is_active = checked ? 1 : 0
|
||||
message.success(checked ? '已启用' : '已禁用(不再参与检索)')
|
||||
emit('changed')
|
||||
} finally { switching.value = 0 }
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
const batchLoading = ref(false)
|
||||
async function doBatch(action) {
|
||||
const labels = { enable: '启用', disable: '禁用', delete: '删除' }
|
||||
const run = async () => {
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const res = await batchChunks({ ids: selectedIds.value, action })
|
||||
message.success(`已${labels[action]} ${res.data?.affected ?? selectedIds.value.length} 个分段`)
|
||||
load()
|
||||
emit('changed')
|
||||
} finally { batchLoading.value = false }
|
||||
}
|
||||
if (action === 'delete') {
|
||||
Modal.confirm({
|
||||
title: `确认删除选中的 ${selectedIds.value.length} 个分段?`,
|
||||
content: '软删除后不再参与检索,且不可在面板恢复',
|
||||
okText: '删除', okType: 'danger', cancelText: '取消',
|
||||
onOk: run
|
||||
})
|
||||
} else {
|
||||
await run()
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(chunk) {
|
||||
editingChunk.value = chunk
|
||||
editOpen.value = true
|
||||
}
|
||||
const activeCount = computed(() => chunks.value.filter(c => c.is_active === 1).length)
|
||||
|
||||
const columns = [
|
||||
{ title: '#', dataIndex: 'chunk_index', key: 'idx', width: 52 },
|
||||
{ title: '内容', key: 'content' },
|
||||
{ title: '启用', key: 'active', width: 70 },
|
||||
{ title: '', key: 'action', width: 46 }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer v-model:open="open" width="800" placement="right">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>分段管理</span>
|
||||
<span class="text-xs" style="color: var(--text-3)">{{ doc?.title }} · {{ activeCount }}/{{ chunks.length }} 启用</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 批量操作条(选中才出现) -->
|
||||
<div v-if="selectedIds.length" class="glass p-2.5 mb-3 flex items-center gap-2">
|
||||
<span class="text-xs" style="color: var(--text-2)">已选 {{ selectedIds.length }} 项</span>
|
||||
<a-button size="small" :loading="batchLoading" @click="doBatch('enable')">批量启用</a-button>
|
||||
<a-button size="small" :loading="batchLoading" @click="doBatch('disable')">批量禁用</a-button>
|
||||
<a-button size="small" danger :loading="batchLoading" @click="doBatch('delete')">批量删除</a-button>
|
||||
<a-button size="small" type="text" @click="selectedIds = []">取消选择</a-button>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="chunks" :loading="loading" row-key="id" size="small"
|
||||
:pagination="{ pageSize: 20, size: 'small', showTotal: t => `共 ${t} 段` }"
|
||||
:row-selection="{ selectedRowKeys: selectedIds, onChange: keys => selectedIds = keys }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'idx'"><span class="mono text-xs" style="color: var(--text-3)">{{ record.chunk_index + 1 }}</span></template>
|
||||
<template v-else-if="column.key === 'content'">
|
||||
<div class="text-xs font-medium mb-0.5 truncate" style="color: var(--text-1); max-width: 480px">{{ record.title || '(无标题)' }}</div>
|
||||
<div class="text-xs" style="color: var(--text-3); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden">{{ record.content }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'active'">
|
||||
<a-switch :checked="record.is_active === 1" size="small" :loading="switching === record.id"
|
||||
@change="checked => toggleActive(record, checked)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button size="small" type="text" @click="openEdit(record)"><template #icon><EditOutlined /></template></a-button>
|
||||
</template>
|
||||
</template>
|
||||
<template #emptyText><div class="py-8 text-xs" style="color: var(--text-3)">该文档暂无分段</div></template>
|
||||
</a-table>
|
||||
|
||||
<ChunkEditModal v-model:open="editOpen" :chunk="editingChunk" @saved="load(); emit('changed')" />
|
||||
</a-drawer>
|
||||
</template>
|
||||
43
src/views/kb/components/CreateLibraryModal.vue
Normal file
43
src/views/kb/components/CreateLibraryModal.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
// 新建知识库弹窗:名称/描述/来源,确认后回调父级刷新
|
||||
import { reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { createLibrary } from '@/api/kb'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const emit = defineEmits(['created'])
|
||||
const loading = ref(false)
|
||||
const form = reactive({ name: '', description: '', source: 'manual' })
|
||||
|
||||
async function submit() {
|
||||
if (!form.name.trim()) { message.warning('请输入库名称'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
await createLibrary({ name: form.name.trim(), description: form.description.trim(), source: form.source })
|
||||
message.success('创建成功')
|
||||
open.value = false
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
emit('created')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal v-model:open="open" title="新建知识库" :confirm-loading="loading" @ok="submit">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="名称" required>
|
||||
<a-input v-model:value="form.name" placeholder="如:中医方剂知识库" :maxlength="50" show-count @press-enter="submit" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" placeholder="这个库放什么内容(可选)" :maxlength="200" />
|
||||
</a-form-item>
|
||||
<a-form-item label="来源标记">
|
||||
<a-radio-group v-model:value="form.source">
|
||||
<a-radio value="manual">手动维护</a-radio>
|
||||
<a-radio value="import">批量导入</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
37
src/views/kb/components/DocPreviewDrawer.vue
Normal file
37
src/views/kb/components/DocPreviewDrawer.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
// 文档原文预览抽屉:基本信息头 + mono 滚动正文
|
||||
import { ref, watch } from 'vue'
|
||||
import { getDoc } from '@/api/kb'
|
||||
import { fmtTimeFull } from '@/utils/format'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const props = defineProps({ docId: { type: Number, default: 0 } })
|
||||
const doc = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
watch([open, () => props.docId], async ([isOpen, id]) => {
|
||||
if (!isOpen || !id) return
|
||||
loading.value = true
|
||||
doc.value = null
|
||||
try {
|
||||
const res = await getDoc(id)
|
||||
doc.value = res.data
|
||||
} finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer v-model:open="open" :title="doc ? doc.title : '文档原文'" width="640" placement="right">
|
||||
<a-skeleton v-if="loading" active :paragraph="{ rows: 10 }" />
|
||||
<template v-else-if="doc">
|
||||
<div class="flex gap-2 mb-3 flex-wrap text-xs" style="color: var(--text-3)">
|
||||
<a-tag :bordered="false">{{ doc.source_type || 'text' }}</a-tag>
|
||||
<span>{{ doc.chunk_count }} 个分段</span>
|
||||
<span>{{ (doc.content || '').length }} 字</span>
|
||||
<span>{{ fmtTimeFull(doc.created_at) }}</span>
|
||||
</div>
|
||||
<div class="mono text-xs p-3 rounded-lg overflow-auto"
|
||||
style="background: rgba(0,0,0,0.15); color: var(--text-2); max-height: calc(100vh - 180px); white-space: pre-wrap">{{ doc.content }}</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
49
src/views/kb/components/RechunkModal.vue
Normal file
49
src/views/kb/components/RechunkModal.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
// 重新分段弹窗:用新 max_len/overlap 对已导入文档重切
|
||||
// 人工编辑过的分段会被覆盖 → 强提示 + 二次确认语义(按钮文案挑明)
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { rechunkDoc } from '@/api/kb'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const props = defineProps({ doc: { type: Object, default: null } })
|
||||
const emit = defineEmits(['done'])
|
||||
const loading = ref(false)
|
||||
const form = reactive({ max_len: 500, overlap: 50 })
|
||||
const result = ref(null)
|
||||
|
||||
watch(open, (v) => { if (v) result.value = null })
|
||||
|
||||
async function submit() {
|
||||
if (form.overlap >= form.max_len) { message.warning('overlap 必须小于 max_len'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await rechunkDoc(props.doc.id, { max_len: form.max_len, overlap: form.overlap })
|
||||
result.value = { ...res.data, max_len: form.max_len, overlap: form.overlap }
|
||||
message.success(`重切完成:${res.data?.old_count ?? '?'} 段 → ${res.data?.chunk_count ?? '?'} 段`)
|
||||
emit('done')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal v-model:open="open" :title="`重新分段:${doc?.title || ''}`" :confirm-loading="loading"
|
||||
ok-text="覆盖并重切" :ok-button-props="{ danger: true }" @ok="submit">
|
||||
<a-alert type="warning" show-icon class="!mb-4"
|
||||
message="将用文档原文按新参数重新切分,原有全部分段(含人工编辑过的内容与启停状态)会被替换" />
|
||||
<a-form layout="vertical">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a-form-item label="分段长度 max_len(100~2000 字)">
|
||||
<a-input-number v-model:value="form.max_len" :min="100" :max="2000" :step="50" class="!w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="重叠长度 overlap(0~200 字)">
|
||||
<a-input-number v-model:value="form.overlap" :min="0" :max="200" :step="10" class="!w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="text-xs" style="color: var(--text-3)">经验值:结构化条目类 300~500 / 长文叙述类 600~800;overlap 取 max_len 的 10% 左右</div>
|
||||
</a-form>
|
||||
<div v-if="result" class="glass p-3 mt-2 text-xs" style="color: var(--text-2)">
|
||||
完成:旧 {{ result.old_count }} 段 → 新 {{ result.chunk_count }} 段(max_len={{ result.max_len }} / overlap={{ result.overlap }})
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user