Files
hms/apps/web/src/pages/ai/KnowledgeV2Page.tsx
iven 2324d770bc feat(web): 知识库 V2 管理页面 — 列表/CRUD/上传/向量搜索测试
- knowledgeV2.ts API client: 知识库/文档/搜索完整接口
- KnowledgeV2Page: 知识库列表 + 创建/编辑/删除
- 文档列表 Drawer: 按知识库查看文档(状态/切片进度)
- 上传 Modal: Multipart 文件上传(PDF/TXT/MD/DOCX/XLSX)
- 向量搜索测试 Drawer: 输入查询 → 余弦相似度结果展示

路由: /health/ai-knowledge-v2
Phase 3 Task 16-19
2026-05-27 00:38:11 +08:00

567 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Switch,
message,
Popconfirm,
Tag,
Upload,
Progress,
Drawer,
List,
Empty,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
UploadOutlined,
SearchOutlined,
FileTextOutlined,
DatabaseOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import {
knowledgeV2Api,
type KnowledgeBase,
type KnowledgeDocument,
type SearchHit,
type CreateKnowledgeBaseReq,
} from '../../api/ai/knowledgeV2';
const KB_TYPES = [
{ label: '临床指南', value: 'clinical_guide' },
{ label: '操作规程', value: 'sop' },
{ label: 'FAQ', value: 'faq' },
{ label: '产品知识', value: 'product' },
{ label: '通用', value: 'general' },
];
export default function KnowledgeV2Page() {
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editKb, setEditKb] = useState<KnowledgeBase | null>(null);
const [form] = Form.useForm();
// Document drawer state
const [docDrawerKb, setDocDrawerKb] = useState<KnowledgeBase | null>(null);
const [docs, setDocs] = useState<KnowledgeDocument[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadKbId, setUploadKbId] = useState<string>('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
// Hit test state
const [hitTestKb, setHitTestKb] = useState<KnowledgeBase | null>(null);
const [hitTestQuery, setHitTestQuery] = useState('');
const [hitResults, setHitResults] = useState<SearchHit[]>([]);
const [hitTestLoading, setHitTestLoading] = useState(false);
const loadKbs = useCallback(async () => {
setLoading(true);
try {
const res = await knowledgeV2Api.listKnowledgeBases({
page,
page_size: 20,
});
setKbs(res.data);
setTotal(res.total);
} catch {
message.error('加载知识库列表失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
loadKbs();
}, [loadKbs]);
const handleCreate = async () => {
try {
const values = await form.validateFields();
const req: CreateKnowledgeBaseReq = {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled ?? true,
};
await knowledgeV2Api.createKnowledgeBase(req);
message.success('知识库创建成功');
setCreateModalOpen(false);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleUpdate = async () => {
if (!editKb) return;
try {
const values = await form.validateFields();
await knowledgeV2Api.updateKnowledgeBase(editKb.id, {
name: values.name,
kb_type: values.kb_type,
description: values.description,
is_enabled: values.is_enabled,
});
message.success('知识库更新成功');
setEditKb(null);
form.resetFields();
loadKbs();
} catch {
// validation error
}
};
const handleDelete = async (id: string) => {
try {
await knowledgeV2Api.deleteKnowledgeBase(id);
message.success('知识库已删除');
loadKbs();
} catch {
message.error('删除失败');
}
};
const loadDocuments = async (kb: KnowledgeBase) => {
setDocDrawerKb(kb);
setDocsLoading(true);
try {
const res = await knowledgeV2Api.listDocuments(kb.id, {
page: 1,
page_size: 50,
});
setDocs(res.data);
} catch {
message.error('加载文档列表失败');
} finally {
setDocsLoading(false);
}
};
const handleUpload = async () => {
if (!uploadKbId || fileList.length === 0) return;
try {
const file = fileList[0].originFileObj;
if (!file) return;
await knowledgeV2Api.uploadDocument(uploadKbId, file);
message.success('文档上传成功,正在处理...');
setUploadModalOpen(false);
setFileList([]);
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('上传失败');
}
};
const handleDeleteDoc = async (kbId: string, docId: string) => {
try {
await knowledgeV2Api.deleteDocument(kbId, docId);
message.success('文档已删除');
if (docDrawerKb) {
loadDocuments(docDrawerKb);
}
} catch {
message.error('删除失败');
}
};
const handleHitTest = async () => {
if (!hitTestKb || !hitTestQuery.trim()) return;
setHitTestLoading(true);
try {
const res = await knowledgeV2Api.hitTest(hitTestKb.id, hitTestQuery, 5);
setHitResults(res.hits);
} catch {
message.error('搜索失败');
setHitResults([]);
} finally {
setHitTestLoading(false);
}
};
const statusTag = (status: string) => {
const map: Record<string, { color: string; label: string }> = {
pending: { color: 'default', label: '待处理' },
processing: { color: 'processing', label: '处理中' },
completed: { color: 'success', label: '已完成' },
failed: { color: 'error', label: '失败' },
};
const info = map[status] || { color: 'default', label: status };
return <Tag color={info.color}>{info.label}</Tag>;
};
const kbColumns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: KnowledgeBase) => (
<Button type="link" onClick={() => loadDocuments(record)}>
{name}
</Button>
),
},
{
title: '类型',
dataIndex: 'kb_type',
key: 'kb_type',
render: (type: string) => {
const found = KB_TYPES.find((t) => t.value === type);
return found?.label || type;
},
},
{
title: '文档数',
dataIndex: 'document_count',
key: 'document_count',
width: 90,
},
{
title: '切片数',
dataIndex: 'chunk_count',
key: 'chunk_count',
width: 90,
},
{
title: '状态',
dataIndex: 'is_enabled',
key: 'is_enabled',
width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 240,
render: (_: unknown, record: KnowledgeBase) => (
<Space size="small">
<Button
size="small"
icon={<UploadOutlined />}
onClick={() => {
setUploadKbId(record.id);
setUploadModalOpen(true);
}}
>
</Button>
<Button
size="small"
icon={<SearchOutlined />}
onClick={() => {
setHitTestKb(record);
setHitResults([]);
setHitTestQuery('');
}}
>
</Button>
<Button
size="small"
onClick={() => {
setEditKb(record);
form.setFieldsValue({
name: record.name,
kb_type: record.kb_type,
description: record.description,
is_enabled: record.is_enabled,
});
}}
>
</Button>
<Popconfirm
title="确定删除此知识库?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const kbFormContent = (
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="知识库名称"
rules={[{ required: true, message: '请输入知识库名称' }]}
>
<Input placeholder="例:高血压临床指南" />
</Form.Item>
<Form.Item
name="kb_type"
label="知识库类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select options={KB_TYPES} placeholder="选择类型" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="知识库描述(可选)" />
</Form.Item>
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
<Switch defaultChecked />
</Form.Item>
</Form>
);
const docColumns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: '类型',
dataIndex: 'doc_type',
key: 'doc_type',
width: 80,
},
{
title: '来源',
dataIndex: 'source_type',
key: 'source_type',
width: 80,
render: (v: string) => <Tag>{v === 'upload' ? '上传' : '手动'}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: statusTag,
},
{
title: '切片/嵌入',
key: 'progress',
width: 130,
render: (_: unknown, record: KnowledgeDocument) => {
if (record.chunk_count === 0) return '-';
const pct = Math.round(
(record.embedded_count / record.chunk_count) * 100,
);
return <Progress percent={pct} size="small" />;
},
},
{
title: '操作',
key: 'actions',
width: 70,
render: (_: unknown, record: KnowledgeDocument) => (
<Popconfirm
title="确定删除此文档?"
onConfirm={() =>
handleDeleteDoc(record.knowledge_base_id, record.id)
}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div style={{ padding: 24 }}>
<Card
title={
<Space>
<DatabaseOutlined />
V2
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
form.resetFields();
setCreateModalOpen(true);
}}
>
</Button>
}
>
<Table
rowKey="id"
columns={kbColumns}
dataSource={kbs}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t}`,
}}
/>
</Card>
{/* 创建知识库 Modal */}
<Modal
title="新建知识库"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => {
setCreateModalOpen(false);
form.resetFields();
}}
okText="创建"
>
{kbFormContent}
</Modal>
{/* 编辑知识库 Modal */}
<Modal
title="编辑知识库"
open={!!editKb}
onOk={handleUpdate}
onCancel={() => {
setEditKb(null);
form.resetFields();
}}
okText="保存"
>
{kbFormContent}
</Modal>
{/* 文档列表 Drawer */}
<Drawer
title={
docDrawerKb
? `${docDrawerKb.name} — 文档列表`
: '文档列表'
}
open={!!docDrawerKb}
onClose={() => setDocDrawerKb(null)}
width={720}
>
<Table
rowKey="id"
columns={docColumns}
dataSource={docs}
loading={docsLoading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="暂无文档" /> }}
/>
</Drawer>
{/* 上传文档 Modal */}
<Modal
title="上传文档"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => {
setUploadModalOpen(false);
setFileList([]);
}}
okText="上传"
>
<Upload
beforeUpload={() => false}
maxCount={1}
fileList={fileList}
onChange={({ fileList: fl }) => setFileList(fl)}
accept=".pdf,.txt,.md,.docx,.xlsx"
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
PDFTXTMarkdownDOCXXLSX 20MB
</div>
</Modal>
{/* Hit Test Drawer */}
<Drawer
title={
hitTestKb ? `${hitTestKb.name} — 向量搜索测试` : '搜索测试'
}
open={!!hitTestKb}
onClose={() => {
setHitTestKb(null);
setHitResults([]);
}}
width={600}
>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Input
placeholder="输入搜索文本..."
value={hitTestQuery}
onChange={(e) => setHitTestQuery(e.target.value)}
onPressEnter={handleHitTest}
/>
<Button
type="primary"
icon={<SearchOutlined />}
loading={hitTestLoading}
onClick={handleHitTest}
>
</Button>
</Space.Compact>
<List
dataSource={hitResults}
locale={{ emptyText: <Empty description="输入查询后点击搜索" /> }}
renderItem={(item) => (
<List.Item key={item.chunk_id}>
<List.Item.Meta
avatar={
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
}
title={
<Space>
<span>{item.doc_title}</span>
<Tag color="blue"> #{item.chunk_index}</Tag>
<Tag color="green">
{(item.similarity * 100).toFixed(1)}%
</Tag>
</Space>
}
description={
<div
style={{
maxHeight: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
color: '#666',
}}
>
{item.content}
</div>
}
/>
</List.Item>
)}
/>
</Drawer>
</div>
);
}