feat: add knowledge page

main
yannyang 2026-07-30 16:18:58 +08:00
parent 23d1df700c
commit aabc979fec
12 changed files with 1273 additions and 1 deletions

View File

@ -17,6 +17,7 @@ import ProfilePage from './pages/ProfilePage';
import PricingPage from './pages/PricingPage'; import PricingPage from './pages/PricingPage';
import SharedSessionPage from './pages/SharedSessionPage'; import SharedSessionPage from './pages/SharedSessionPage';
import WorkflowsPage from './pages/WorkflowsPage'; import WorkflowsPage from './pages/WorkflowsPage';
import KnowledgeBasePage from './pages/KnowledgeBase';
import { useAuth } from './store/auth'; import { useAuth } from './store/auth';
import { AgentAPI } from './api'; import { AgentAPI } from './api';
import { useIsMobile } from './hooks/useIsMobile'; import { useIsMobile } from './hooks/useIsMobile';
@ -64,6 +65,7 @@ export default function App() {
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/pricing" element={<PricingPage />} /> <Route path="/pricing" element={<PricingPage />} />
<Route path="/workflows" element={<WorkflowsPage />} /> <Route path="/workflows" element={<WorkflowsPage />} />
<Route path="/knowledge" element={<KnowledgeBasePage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );

View File

@ -16,4 +16,5 @@ export * from './llmProviders';
export * from './streamChat'; export * from './streamChat';
export * from './workflows'; export * from './workflows';
export * from './membership'; export * from './membership';
export * from './knowledgeBase';

55
src/api/knowledgeBase.ts Normal file
View File

@ -0,0 +1,55 @@
import { api } from './http';
export interface KBDTO {
id: string;
name: string;
description: string;
ownerId: string;
isPublic: boolean;
createdAt: number;
updatedAt: number;
access: 'owner' | 'view' | 'none';
}
export interface KBFileDTO {
id: string;
originalName: string;
filename: string;
size: number;
status: 'indexing' | 'ready' | 'error';
chunkCount?: number;
createdAt?: number;
}
export const KnowledgeBaseAPI = {
// 核心管理
listKBs: () => api.get<{ data: KBDTO[] }>('/kbs').then((r) => r.data.data),
getKB: (id: string) => api.get<{ data: KBDTO }>(`/kbs/${id}`).then((r) => r.data.data),
createKB: (data: { name: string; description: string; isPublic: boolean }) =>
api.post<{ data: KBDTO }>('/kbs', data).then((r) => r.data.data),
updateKB: (id: string, data: Partial<{ name: string; description: string; isPublic: boolean }>) =>
api.patch<{ data: KBDTO }>(`/kbs/${id}`, data).then((r) => r.data.data),
deleteKB: (id: string) => api.delete(`/kbs/${id}`).then((r) => r.data),
// 文件管理
listFiles: (id: string) => api.get<{ data: KBFileDTO[] }>(`/kbs/${id}/files`).then((r) => r.data.data),
uploadFiles: (id: string, files: File[]) => {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
return api.post<{ data: KBFileDTO[] }>(`/kbs/${id}/files`, formData).then((r) => r.data.data);
},
removeFile: (kbId: string, fileId: string) =>
api.delete(`/kbs/${kbId}/files/${fileId}`).then((r) => r.data),
// 授权与共享
shareKB: (id: string, data: { subjectType: 'user' | 'team'; subjectId: string }) =>
api.post(`/kbs/${id}/shares`, data).then((r) => r.data),
unshareKB: (id: string, data: { subjectType: 'user' | 'team'; subjectId: string }) =>
api.delete(`/kbs/${id}/shares`, { data }).then((r) => r.data),
// 智能体关联
mountToAgent: (agentId: string, kbId: string) =>
api.post(`/agents/${agentId}/kbs/${kbId}`).then((r) => r.data),
unmountFromAgent: (agentId: string, kbId: string) =>
api.delete(`/agents/${agentId}/kbs/${kbId}`).then((r) => r.data),
};

View File

@ -14,7 +14,8 @@ import {
RightOutlined, RightOutlined,
UserOutlined, UserOutlined,
CreditCardOutlined, CreditCardOutlined,
ShoppingCartOutlined ShoppingCartOutlined,
DatabaseOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAuth } from '../store/auth'; import { useAuth } from '../store/auth';
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png'; import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
@ -42,6 +43,7 @@ const NAV_GROUPS: Array<{
{ {
label: '资源', label: '资源',
items: [ items: [
{ to: '/knowledge', icon: <DatabaseOutlined />, label: '知识库' },
{ to: '/prompts', icon: <BookOutlined />, label: 'Prompt 模板库' }, { to: '/prompts', icon: <BookOutlined />, label: 'Prompt 模板库' },
{ to: '/workflows', icon: <ApartmentOutlined />, label: '工作流' } { to: '/workflows', icon: <ApartmentOutlined />, label: '工作流' }
] ]

View File

@ -0,0 +1,150 @@
import { useState, useEffect, useCallback } from 'react';
import { message } from 'antd';
import { KnowledgeBaseAPI, KBDTO, KBFileDTO } from '../../api/knowledgeBase';
import { useAuth } from '../../store/auth';
export function useKnowledgeBaseLogic() {
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [kbs, setKbs] = useState<KBDTO[]>([]);
const [activeKb, setActiveKb] = useState<KBDTO | null>(null);
const [files, setFiles] = useState<KBFileDTO[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
// 弹窗状态
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
// 获取知识库列表
const loadKBs = useCallback(async () => {
setLoading(true);
try {
const data = await KnowledgeBaseAPI.listKBs();
setKbs(data);
// 如果没有选中的,默认选第一个
if (data.length > 0 && !activeKb) {
setActiveKb(data[0]);
} else if (activeKb) {
// 刷新当前选中的 KB 信息
const updated = data.find(kb => kb.id === activeKb.id);
if (updated) setActiveKb(updated);
}
} catch (err) {
message.error('加载知识库失败');
} finally {
setLoading(false);
}
}, [activeKb]);
// 获取当前 KB 的文件列表
const loadFiles = useCallback(async (kbId: string) => {
setFilesLoading(true);
try {
const data = await KnowledgeBaseAPI.listFiles(kbId);
setFiles(data);
} catch (err) {
message.error('加载文件列表失败');
} finally {
setFilesLoading(false);
}
}, []);
useEffect(() => {
loadKBs();
}, []);
useEffect(() => {
if (activeKb) {
loadFiles(activeKb.id);
} else {
setFiles([]);
}
}, [activeKb?.id, loadFiles]);
// CRUD 操作
const handleCreate = async (values: { name: string; description: string; isPublic: boolean }) => {
try {
const newKb = await KnowledgeBaseAPI.createKB(values);
message.success('创建成功');
setIsCreateModalOpen(false);
await loadKBs();
setActiveKb(newKb);
} catch (err) {
message.error('创建失败');
}
};
const handleUpdate = async (values: Partial<KBDTO>) => {
if (!activeKb) return;
try {
await KnowledgeBaseAPI.updateKB(activeKb.id, values);
message.success('更新成功');
setIsEditModalOpen(false);
await loadKBs();
} catch (err) {
message.error('更新失败');
}
};
const handleDelete = async (id: string) => {
try {
await KnowledgeBaseAPI.deleteKB(id);
message.success('删除成功');
if (activeKb?.id === id) {
setActiveKb(null);
}
await loadKBs();
} catch (err) {
message.error('删除失败');
}
};
const handleUpload = async (fileList: File[]) => {
if (!activeKb) return;
try {
await KnowledgeBaseAPI.uploadFiles(activeKb.id, fileList);
message.success('上传成功');
setIsUploadModalOpen(false);
await loadFiles(activeKb.id);
} catch (err) {
message.error('上传失败');
}
};
const handleRemoveFile = async (fileId: string) => {
if (!activeKb) return;
try {
await KnowledgeBaseAPI.removeFile(activeKb.id, fileId);
message.success('文件已移除');
await loadFiles(activeKb.id);
} catch (err) {
message.error('移除失败');
}
};
return {
loading,
kbs,
activeKb,
setActiveKb,
files,
filesLoading,
isCreateModalOpen,
setIsCreateModalOpen,
isEditModalOpen,
setIsEditModalOpen,
isShareModalOpen,
setIsShareModalOpen,
isUploadModalOpen,
setIsUploadModalOpen,
handleCreate,
handleUpdate,
handleDelete,
handleUpload,
handleRemoveFile,
refreshKBs: loadKBs,
refreshFiles: () => activeKb && loadFiles(activeKb.id),
};
}

View File

@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { Button, Table, Space, Tag, Popconfirm, Tooltip, Dropdown, MenuProps } from 'antd';
import {
UploadOutlined,
ShareAltOutlined,
DeleteOutlined,
EditOutlined,
FileTextOutlined,
EllipsisOutlined,
RobotOutlined
} from '@ant-design/icons';
import { KBFileDTO } from '../../../api/knowledgeBase';
import { AgentAPI, Agent } from '../../../api/agents';
import { useAuth } from '../../../store/auth';
import dayjs from 'dayjs';
interface Props {
logic: any; // 简化类型,实际开发中建议定义完整接口
}
export default function KBDetail({ logic }: Props) {
const { user } = useAuth();
const {
activeKb,
files,
filesLoading,
setIsEditModalOpen,
setIsShareModalOpen,
setIsUploadModalOpen,
handleDelete,
handleRemoveFile
} = logic;
const [agents, setAgents] = useState<Agent[]>([]);
useEffect(() => {
// 获取智能体列表,用于挂载
const fetchAgents = async () => {
if (!user?.phone) return;
try {
const data = await AgentAPI.mine(user.phone);
setAgents(data);
} catch (err) {
console.error('获取智能体列表失败', err);
}
};
fetchAgents();
}, [user?.phone]);
const columns = [
{
title: '文件名',
dataIndex: 'originalName',
key: 'originalName',
render: (text: string) => (
<Space>
<FileTextOutlined style={{ color: 'var(--color-text-tertiary)' }} />
<span className="kb-file-name">{text}</span>
</Space>
),
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
width: 100,
render: (size: number) => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
},
},
{
title: '分块数',
dataIndex: 'chunkCount',
key: 'chunkCount',
width: 80,
render: (count: number) => count ?? '-',
},
{
title: '上传时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (time: number) => time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: KBFileDTO['status']) => {
const config = {
indexing: { color: 'processing', text: '索引中' },
ready: { color: 'success', text: '就绪' },
error: { color: 'error', text: '失败' },
};
const item = config[status] || config.indexing;
return <Tag color={item.color} bordered={false}>{item.text}</Tag>;
},
},
{
title: '操作',
key: 'action',
width: 80,
render: (_: any, record: KBFileDTO) => (
<Popconfirm
title="确定要移除此文件吗?"
description="移除后将无法在对话中检索此文件内容。"
onConfirm={() => handleRemoveFile(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
),
},
];
const menuItems: MenuProps['items'] = [
{
key: 'edit',
label: '编辑信息',
icon: <EditOutlined />,
onClick: () => setIsEditModalOpen(true),
},
{
key: 'share',
label: '共享设置',
icon: <ShareAltOutlined />,
onClick: () => setIsShareModalOpen(true),
},
{
type: 'divider',
},
{
key: 'delete',
label: '删除知识库',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDelete(activeKb.id),
},
];
return (
<div className="kb-detail">
<div className="kb-detail-header">
<div className="kb-detail-info">
<h2 className="kb-detail-title">{activeKb.name}</h2>
<p className="kb-detail-desc">{activeKb.description || '暂无描述'}</p>
</div>
<div className="kb-detail-actions">
<Space>
<Button
icon={<RobotOutlined />}
onClick={() => setIsShareModalOpen(true)} // 暂时复用共享弹窗或后续独立
>
</Button>
<Button
icon={<ShareAltOutlined />}
onClick={() => setIsShareModalOpen(true)}
>
</Button>
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => setIsUploadModalOpen(true)}
>
</Button>
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
<Button icon={<EllipsisOutlined />} />
</Dropdown>
</Space>
</div>
</div>
<div className="kb-detail-content">
<div className="kb-detail-section-head">
<h3 className="kb-detail-section-title"></h3>
<span className="kb-detail-section-count"> {files.length} </span>
</div>
<Table
columns={columns}
dataSource={files}
rowKey="id"
loading={filesLoading}
pagination={{ pageSize: 10, hideOnSinglePage: true }}
className="kb-files-table"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import { Spin, Tag, Empty } from 'antd';
import { GlobalOutlined, LockOutlined, TeamOutlined } from '@ant-design/icons';
import { KBDTO } from '../../../api/knowledgeBase';
interface Props {
kbs: KBDTO[];
activeId?: string;
onSelect: (kb: KBDTO) => void;
loading: boolean;
}
export default function KBList({ kbs, activeId, onSelect, loading }: Props) {
if (loading && kbs.length === 0) {
return (
<div className="kb-page-web-list-loading">
<Spin />
</div>
);
}
if (kbs.length === 0) {
return (
<div className="kb-page-web-list-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无知识库" />
</div>
);
}
return (
<div className="kb-page-web-list-items">
{kbs.map((kb) => (
<button
key={kb.id}
className={`kb-page-web-nav-item ${activeId === kb.id ? 'active' : ''}`}
onClick={() => onSelect(kb)}
>
<div className="kb-page-web-nav-item-content">
<div className="kb-page-web-nav-item-top">
<span className="kb-page-web-nav-item-name">{kb.name}</span>
{kb.isPublic ? (
<GlobalOutlined className="kb-page-web-nav-item-type-icon public" />
) : kb.access === 'owner' ? (
<LockOutlined className="kb-page-web-nav-item-type-icon private" />
) : (
<TeamOutlined className="kb-page-web-nav-item-type-icon team" />
)}
</div>
<div className="kb-page-web-nav-item-bottom">
<span className="kb-page-web-nav-item-desc">{kb.description || '暂无描述'}</span>
</div>
<div className="kb-page-web-nav-item-tags">
<Tag color={kb.access === 'owner' ? 'blue' : 'orange'} bordered={false} className="kb-page-web-access-tag">
{kb.access === 'owner' ? '所有者' : '查看者'}
</Tag>
</div>
</div>
</button>
))}
</div>
);
}

View File

@ -0,0 +1,274 @@
import { useState, useEffect } from 'react';
import {
Modal,
Form,
Input,
Switch,
Button,
Upload,
Tabs,
Select,
Space,
message as antdMessage
} from 'antd';
import { InboxOutlined, UserOutlined, TeamOutlined, RobotOutlined } from '@ant-design/icons';
import { KnowledgeBaseAPI } from '../../../api/knowledgeBase';
import { TeamAPI, Team } from '../../../api/teams';
import { AgentAPI, Agent } from '../../../api/agents';
import { useAuth } from '../../../store/auth';
const { Dragger } = Upload;
interface Props {
logic: any;
}
export default function KBModals({ logic }: Props) {
const { user } = useAuth();
const {
activeKb,
isCreateModalOpen, setIsCreateModalOpen,
isEditModalOpen, setIsEditModalOpen,
isShareModalOpen, setIsShareModalOpen,
isUploadModalOpen, setIsUploadModalOpen,
handleCreate,
handleUpdate,
handleUpload,
refreshFiles
} = logic;
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [fileList, setFileList] = useState<any[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [sharingLoading, setSharingLoading] = useState(false);
// 选中的目标状态
const [selectedAgentId, setSelectedAgentId] = useState<string>();
const [selectedTeamId, setSelectedTeamId] = useState<string>();
const [selectedUserId, setSelectedUserId] = useState<string>('');
useEffect(() => {
if (isEditModalOpen && activeKb) {
editForm.setFieldsValue(activeKb);
}
}, [isEditModalOpen, activeKb, editForm]);
useEffect(() => {
if (isShareModalOpen) {
TeamAPI.list().then(setTeams).catch(() => {});
if (user?.phone) {
AgentAPI.mine(user.phone).then(setAgents).catch(() => {});
}
// 重置选择
setSelectedAgentId(undefined);
setSelectedTeamId(undefined);
setSelectedUserId('');
}
}, [isShareModalOpen, user?.phone]);
const onShare = async (type: 'user' | 'team', id: string) => {
if (!activeKb) return;
setSharingLoading(true);
try {
await KnowledgeBaseAPI.shareKB(activeKb.id, { subjectType: type, subjectId: id });
antdMessage.success('共享成功');
} catch (err) {
antdMessage.error('共享失败');
} finally {
setSharingLoading(false);
}
};
const onMount = async (agentId: string) => {
if (!activeKb) return;
setSharingLoading(true);
try {
await KnowledgeBaseAPI.mountToAgent(agentId, activeKb.id);
antdMessage.success('已挂载到智能体');
} catch (err) {
antdMessage.error('挂载失败');
} finally {
setSharingLoading(false);
}
};
return (
<>
{/* Create Modal */}
<Modal
title="创建知识库"
open={isCreateModalOpen}
onCancel={() => setIsCreateModalOpen(false)}
footer={null}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={handleCreate}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:技术产品文档" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea placeholder="简要描述知识库的用途" rows={3} />
</Form.Item>
<Form.Item name="isPublic" label="公开访问" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setIsCreateModalOpen(false)}></Button>
<Button type="primary" htmlType="submit"></Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* Edit Modal */}
<Modal
title="编辑知识库"
open={isEditModalOpen}
onCancel={() => setIsEditModalOpen(false)}
footer={null}
destroyOnHidden
>
<Form form={editForm} layout="vertical" onFinish={handleUpdate}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="isPublic" label="公开访问" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Space>
<Button onClick={() => setIsEditModalOpen(false)}></Button>
<Button type="primary" htmlType="submit"></Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* Upload Modal */}
<Modal
title="上传文件"
open={isUploadModalOpen}
onCancel={() => setIsUploadModalOpen(false)}
onOk={() => handleUpload(fileList.map(f => f.originFileObj))}
okText="开始上传"
cancelText="取消"
destroyOnHidden
>
<Dragger
multiple
fileList={fileList}
onChange={({ fileList }) => setFileList(fileList)}
beforeUpload={() => false}
>
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> 50MB</p>
</Dragger>
</Modal>
{/* Share & Mount Modal */}
<Modal
title="共享与授权"
open={isShareModalOpen}
onCancel={() => setIsShareModalOpen(false)}
footer={null}
width={600}
>
<Tabs items={[
{
key: 'agent',
label: <span><RobotOutlined /></span>,
children: (
<div style={{ padding: '10px 0' }}>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 12 }}>
</p>
<Space.Compact style={{ width: '100%' }}>
<Select
showSearch
placeholder="选择智能体"
style={{ flex: 1 }}
value={selectedAgentId}
onChange={setSelectedAgentId}
options={agents.map(a => ({ label: a.name, value: a.id }))}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
<Button
type="primary"
loading={sharingLoading}
disabled={!selectedAgentId}
onClick={() => selectedAgentId && onMount(selectedAgentId)}
>
</Button>
</Space.Compact>
</div>
)
},
{
key: 'team',
label: <span><TeamOutlined /></span>,
children: (
<div style={{ padding: '10px 0' }}>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 12 }}>
</p>
<Space.Compact style={{ width: '100%' }}>
<Select
placeholder="选择团队"
style={{ flex: 1 }}
value={selectedTeamId}
onChange={setSelectedTeamId}
options={teams.map(t => ({ label: t.name, value: t.id }))}
/>
<Button
type="primary"
loading={sharingLoading}
disabled={!selectedTeamId}
onClick={() => selectedTeamId && onShare('team', selectedTeamId)}
>
</Button>
</Space.Compact>
</div>
)
},
{
key: 'user',
label: <span><UserOutlined /></span>,
children: (
<div style={{ padding: '10px 0' }}>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 12 }}>
ID
</p>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入用户标识"
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
/>
<Button
type="primary"
loading={sharingLoading}
disabled={!selectedUserId.trim()}
onClick={() => selectedUserId.trim() && onShare('user', selectedUserId.trim())}
>
</Button>
</Space.Compact>
</div>
)
}
]} />
</Modal>
</>
);
}

View File

@ -0,0 +1,131 @@
import { useState } from 'react';
import { Button, List, Tag, Space, Empty, Spin } from 'antd';
import {
PlusOutlined,
DatabaseOutlined,
FileTextOutlined,
ShareAltOutlined,
DeleteOutlined,
ArrowLeftOutlined
} from '@ant-design/icons';
import { useKnowledgeBaseLogic } from '../KnowledgeBaseLogic';
import KBModals from './KBModals';
import dayjs from 'dayjs';
import '../styles/knowledge-base.css';
export default function KnowledgeBaseH5() {
const logic = useKnowledgeBaseLogic();
const {
loading,
kbs,
activeKb,
setActiveKb,
files,
filesLoading,
setIsCreateModalOpen,
setIsUploadModalOpen,
setIsShareModalOpen,
handleRemoveFile
} = logic;
const [view, setView] = useState<'list' | 'detail'>('list');
const handleSelect = (kb: any) => {
setActiveKb(kb);
setView('detail');
};
if (view === 'detail' && activeKb) {
return (
<div className="kb-page-h5-detail">
<div className="kb-h5-header">
<Button icon={<ArrowLeftOutlined />} type="text" onClick={() => setView('list')} />
<span className="kb-h5-header-title">{activeKb.name}</span>
<Button icon={<PlusOutlined />} type="text" onClick={() => setIsUploadModalOpen(true)} />
</div>
<div className="kb-h5-content">
<div className="kb-h5-actions">
<Space>
<Button icon={<ShareAltOutlined />} onClick={() => setIsShareModalOpen(true)}></Button>
<Button danger icon={<DeleteOutlined />} onClick={() => logic.handleDelete(activeKb.id)}></Button>
</Space>
</div>
<div className="kb-h5-section-title"> ({files.length})</div>
{filesLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : (
<List
dataSource={files}
renderItem={(file: any) => (
<List.Item
actions={[
<Button
key="del"
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleRemoveFile(file.id)}
/>
]}
>
<List.Item.Meta
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#999' }} />}
title={file.originalName}
description={
<div className="kb-h5-file-desc">
<span>{(file.size / 1024).toFixed(1)} KB</span>
{file.chunkCount && <span> · {file.chunkCount} </span>}
{file.createdAt && <span> · {dayjs(file.createdAt).format('MM-DD HH:mm')}</span>}
<span> · {file.status}</span>
</div>
}
/>
</List.Item>
)}
/>
)}
</div>
<KBModals logic={logic} />
</div>
);
}
return (
<div className="kb-page-h5">
<div className="kb-h5-hero">
<h1 className="kb-h5-title"></h1>
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalOpen(true)}
/>
</div>
<div className="kb-h5-list">
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : kbs.length === 0 ? (
<Empty description="暂无知识库" />
) : (
<List
dataSource={kbs}
renderItem={(kb: any) => (
<List.Item onClick={() => handleSelect(kb)} className="kb-h5-list-item">
<List.Item.Meta
avatar={<DatabaseOutlined style={{ fontSize: 24, color: 'var(--color-brand)' }} />}
title={kb.name}
description={kb.description || '暂无描述'}
/>
<Tag bordered={false}>{kb.access === 'owner' ? '所有者' : '查看者'}</Tag>
</List.Item>
)}
/>
)}
</div>
<KBModals logic={logic} />
</div>
);
}

View File

@ -0,0 +1,85 @@
import { Button, Spin, Empty } from 'antd';
import { PlusOutlined, DatabaseOutlined } from '@ant-design/icons';
import { useKnowledgeBaseLogic } from '../KnowledgeBaseLogic';
import KBList from './KBList';
import KBDetail from './KBDetail';
import KBModals from './KBModals';
import '../styles/knowledge-base.css';
export default function KnowledgeBaseWeb() {
const logic = useKnowledgeBaseLogic();
const {
loading,
kbs,
activeKb,
setActiveKb,
setIsCreateModalOpen
} = logic;
return (
<div className="kb-page-web">
{/* Hero Section */}
<header className="kb-page-web-hero">
<div className="kb-page-web-hero-header">
<div className="kb-page-web-hero-copy">
<div className="kb-page-web-badge">
<DatabaseOutlined />
<span>KNOWLEDGE BASE</span>
</div>
<h1 className="kb-page-web-title"></h1>
<p className="kb-page-web-subtitle">
AI
</p>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
className="kb-page-web-create-btn"
onClick={() => setIsCreateModalOpen(true)}
>
</Button>
</div>
</header>
{/* Main Content */}
<div className="kb-page-web-main-grid">
{/* Left Sidebar: KB List */}
<aside className="kb-page-web-list-panel">
<div className="kb-page-web-list-head">
<h3 className="kb-page-web-section-title"></h3>
<p className="kb-page-web-section-desc"> {kbs.length} </p>
</div>
<KBList
kbs={kbs}
activeId={activeKb?.id}
onSelect={setActiveKb}
loading={loading}
/>
</aside>
{/* Right Content: KB Detail */}
<main className="kb-page-web-detail-container">
{loading ? (
<div className="kb-page-web-loading">
<Spin size="large" />
</div>
) : activeKb ? (
<KBDetail logic={logic} />
) : (
<div className="kb-page-web-empty">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="选择一个知识库以查看详情"
/>
</div>
)}
</main>
</div>
{/* Modals */}
<KBModals logic={logic} />
</div>
);
}

View File

@ -0,0 +1,8 @@
import { useIsMobile } from '../../hooks/useIsMobile';
import KnowledgeBaseWeb from './components/KnowledgeBaseWeb';
import KnowledgeBaseH5 from './components/KnowledgeBaseH5';
export default function KnowledgeBasePage() {
const isMobile = useIsMobile();
return isMobile ? <KnowledgeBaseH5 /> : <KnowledgeBaseWeb />;
}

View File

@ -0,0 +1,307 @@
.kb-page-web {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.kb-page-web-hero {
border-radius: 24px;
padding: 30px 30px 26px;
margin-bottom: 24px;
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96));
border: 1px solid rgba(8, 145, 178, 0.12);
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.06);
}
.kb-page-web-hero-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.kb-page-web-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
margin-bottom: 16px;
background: rgba(255,255,255,0.78);
border: 1px solid rgba(8, 145, 178, 0.1);
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 700;
}
.kb-page-web-badge .anticon {
color: var(--color-brand);
}
.kb-page-web-title {
margin-bottom: 8px;
font-size: 28px;
font-weight: 850;
color: var(--color-text);
letter-spacing: -0.02em;
}
.kb-page-web-subtitle {
margin: 0;
font-size: 14.5px;
line-height: 1.6;
color: var(--color-text-secondary);
max-width: 580px;
}
.kb-page-web-create-btn {
border-radius: 14px !important;
height: 46px !important;
padding: 0 18px !important;
font-weight: 600 !important;
}
.kb-page-web-main-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 24px;
align-items: stretch;
}
.kb-page-web-list-panel {
background: white;
border: 1px solid var(--color-border);
border-radius: 22px;
padding: 16px;
height: fit-content;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.045);
}
.kb-page-web-list-head {
padding: 0 8px 16px;
border-bottom: 1px solid var(--color-border-faint);
margin-bottom: 12px;
}
.kb-page-web-section-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 800;
color: var(--color-text);
}
.kb-page-web-section-desc {
margin: 0;
font-size: 12px;
color: var(--color-text-tertiary);
}
.kb-page-web-list-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.kb-page-web-nav-item {
width: 100%;
border: 1px solid transparent;
border-radius: 14px;
padding: 12px;
background: transparent;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.kb-page-web-nav-item:hover {
background: var(--color-surface-2);
}
.kb-page-web-nav-item.active {
background: var(--color-brand-soft);
border-color: rgba(8, 145, 178, 0.12);
}
.kb-page-web-nav-item-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.kb-page-web-nav-item-name {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.kb-page-web-nav-item-type-icon {
font-size: 12px;
color: var(--color-text-tertiary);
}
.kb-page-web-nav-item-type-icon.public { color: var(--color-success); }
.kb-page-web-nav-item-type-icon.private { color: var(--color-brand); }
.kb-page-web-nav-item-type-icon.team { color: var(--color-info); }
.kb-page-web-nav-item-desc {
font-size: 12px;
color: var(--color-text-tertiary);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.kb-page-web-nav-item-tags {
margin-top: 8px;
}
.kb-page-web-access-tag {
font-size: 10px;
padding: 0 6px;
border-radius: 4px;
}
.kb-page-web-detail-container {
background: white;
border: 1px solid var(--color-border);
border-radius: 22px;
min-height: 500px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.045);
}
.kb-detail {
padding: 24px;
}
.kb-detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--color-border-faint);
}
.kb-detail-title {
margin: 0 0 8px;
font-size: 22px;
font-weight: 800;
color: var(--color-text);
}
.kb-detail-desc {
margin: 0;
font-size: 14px;
color: var(--color-text-secondary);
}
.kb-detail-section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.kb-detail-section-title {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.kb-detail-section-count {
font-size: 12px;
color: var(--color-text-tertiary);
}
.kb-file-name {
font-weight: 500;
color: var(--color-text);
}
.kb-files-table .ant-table-thead > tr > th {
background: transparent;
font-size: 13px;
font-weight: 600;
color: var(--color-text-tertiary);
}
.kb-page-web-loading,
.kb-page-web-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
/* Dark mode adjustments if needed */
[data-theme='dark'] .kb-page-web-hero {
background: linear-gradient(135deg, #1e293b, #0f172a);
border-color: rgba(255, 255, 255, 0.08);
}
[data-theme='dark'] .kb-page-web-list-panel,
[data-theme='dark'] .kb-page-web-detail-container {
background: #1e293b;
border-color: rgba(255, 255, 255, 0.08);
}
/* H5 Styles */
.kb-page-h5 {
padding: 16px;
}
.kb-h5-hero {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.kb-h5-title {
font-size: 24px;
font-weight: 800;
margin: 0;
}
.kb-h5-list-item {
padding: 16px !important;
background: white;
border-radius: 12px;
margin-bottom: 12px;
border: 1px solid var(--color-border) !important;
}
.kb-h5-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: white;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 10;
}
.kb-h5-header-title {
flex: 1;
text-align: center;
font-weight: 600;
font-size: 16px;
}
.kb-h5-content {
padding: 16px;
}
.kb-h5-actions {
margin-bottom: 20px;
}
.kb-h5-section-title {
font-weight: 700;
margin-bottom: 12px;
font-size: 14px;
color: var(--color-text-secondary);
}