feat: add knowledge page
parent
23d1df700c
commit
aabc979fec
|
|
@ -17,6 +17,7 @@ import ProfilePage from './pages/ProfilePage';
|
|||
import PricingPage from './pages/PricingPage';
|
||||
import SharedSessionPage from './pages/SharedSessionPage';
|
||||
import WorkflowsPage from './pages/WorkflowsPage';
|
||||
import KnowledgeBasePage from './pages/KnowledgeBase';
|
||||
import { useAuth } from './store/auth';
|
||||
import { AgentAPI } from './api';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
|
|
@ -64,6 +65,7 @@ export default function App() {
|
|||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/workflows" element={<WorkflowsPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgeBasePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ export * from './llmProviders';
|
|||
export * from './streamChat';
|
||||
export * from './workflows';
|
||||
export * from './membership';
|
||||
export * from './knowledgeBase';
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
@ -14,7 +14,8 @@ import {
|
|||
RightOutlined,
|
||||
UserOutlined,
|
||||
CreditCardOutlined,
|
||||
ShoppingCartOutlined
|
||||
ShoppingCartOutlined,
|
||||
DatabaseOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../store/auth';
|
||||
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||
|
|
@ -42,6 +43,7 @@ const NAV_GROUPS: Array<{
|
|||
{
|
||||
label: '资源',
|
||||
items: [
|
||||
{ to: '/knowledge', icon: <DatabaseOutlined />, label: '知识库' },
|
||||
{ to: '/prompts', icon: <BookOutlined />, label: 'Prompt 模板库' },
|
||||
{ to: '/workflows', icon: <ApartmentOutlined />, label: '工作流' }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue