feat: 知识库页优化——文档列表与检索结果分页;目录来源/文档列表移到检索上方;目录扫描后台化并推送进度条与处理统计(处理/成功/失败/忽略/分块)
This commit is contained in:
+37
-7
@@ -261,9 +261,30 @@ export interface KbSourceInput {
|
||||
recursive: boolean;
|
||||
}
|
||||
|
||||
export interface KbSourceScanResult {
|
||||
export interface KbDocumentPage {
|
||||
items: KbDocument[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface KbSearchPage {
|
||||
items: KbSearchHit[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface KbScanProgressEvent {
|
||||
kb_id: string;
|
||||
source_path: string;
|
||||
status: string;
|
||||
current: number;
|
||||
total: number;
|
||||
imported: number;
|
||||
failed: number;
|
||||
ignored: number;
|
||||
chunks: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@@ -491,8 +512,12 @@ export const api = {
|
||||
invoke<void>("update_knowledge_base", { id, input }),
|
||||
removeKnowledgeBase: (id: string) =>
|
||||
invoke<void>("remove_knowledge_base", { id }),
|
||||
listKbDocuments: (kbId: string) =>
|
||||
invoke<KbDocument[]>("list_kb_documents", { kbId }),
|
||||
listKbDocuments: (kbId: string, page?: number, pageSize?: number) =>
|
||||
invoke<KbDocumentPage>("list_kb_documents", {
|
||||
kbId,
|
||||
page: page ?? 1,
|
||||
pageSize: pageSize ?? 10,
|
||||
}),
|
||||
getKbDocument: (documentId: string) =>
|
||||
invoke<KbDocumentDetail>("get_kb_document", { documentId }),
|
||||
kbImportDocuments: (
|
||||
@@ -503,13 +528,18 @@ export const api = {
|
||||
invoke<void>("remove_kb_document", { documentId }),
|
||||
kbRechunkDocument: (documentId: string) =>
|
||||
invoke<number>("kb_rechunk_document", { documentId }),
|
||||
kbSearch: (kbId: string | null, query: string, limit?: number) =>
|
||||
invoke<KbSearchHit[]>("kb_search", { kbId, query, limit: limit ?? 10 }),
|
||||
kbSearch: (kbId: string | null, query: string, page?: number, pageSize?: number) =>
|
||||
invoke<KbSearchPage>("kb_search", {
|
||||
kbId,
|
||||
query,
|
||||
page: page ?? 1,
|
||||
pageSize: pageSize ?? 10,
|
||||
}),
|
||||
kbListSources: (kbId: string) => invoke<KbSource[]>("kb_list_sources", { kbId }),
|
||||
kbAddSource: (input: KbSourceInput) =>
|
||||
invoke<KbSourceScanResult>("kb_add_source", { input }),
|
||||
invoke<void>("kb_add_source", { input }),
|
||||
kbScanSources: (kbId: string) =>
|
||||
invoke<KbSourceScanResult>("kb_scan_sources", { kbId }),
|
||||
invoke<void>("kb_scan_sources", { kbId }),
|
||||
kbRemoveSource: (sourceId: string) =>
|
||||
invoke<number>("kb_remove_source", { sourceId }),
|
||||
listModels: () => invoke<ModelInfo[]>("list_models"),
|
||||
|
||||
@@ -4,14 +4,18 @@ import {
|
||||
api,
|
||||
KbDocument,
|
||||
KbDocumentDetail,
|
||||
KbScanProgressEvent,
|
||||
KbSearchHit,
|
||||
KbSource,
|
||||
KbSourceScanResult,
|
||||
KnowledgeBase,
|
||||
KnowledgeBaseInput,
|
||||
onEvent,
|
||||
} from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
const DOC_PAGE_SIZE = 10;
|
||||
const SEARCH_PAGE_SIZE = 10;
|
||||
|
||||
function fmtSize(bytes: number) {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
@@ -48,6 +52,8 @@ export default function KnowledgeBasePage() {
|
||||
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [docs, setDocs] = useState<KbDocument[]>([]);
|
||||
const [docTotal, setDocTotal] = useState(0);
|
||||
const [docPage, setDocPage] = useState(1);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [kbModal, setKbModal] = useState<{
|
||||
@@ -59,10 +65,16 @@ export default function KnowledgeBasePage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<KbSearchHit[] | null>(null);
|
||||
const [searchTotal, setSearchTotal] = useState(0);
|
||||
const [searchPage, setSearchPage] = useState(1);
|
||||
const [sources, setSources] = useState<KbSource[]>([]);
|
||||
const [sourceModal, setSourceModal] = useState(false);
|
||||
const [sourceMsg, setSourceMsg] = useState<string | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanProgress, setScanProgress] = useState<KbScanProgressEvent | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const selectedIdRef = useRef<string | null>(null);
|
||||
const docPageRef = useRef(1);
|
||||
|
||||
const selected = useMemo(
|
||||
() => kbs.find((k) => k.id === selectedId) ?? null,
|
||||
@@ -79,27 +91,73 @@ export default function KnowledgeBasePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocPage(kbId: string, page: number) {
|
||||
const p = await api.listKbDocuments(kbId, page, DOC_PAGE_SIZE);
|
||||
if (p.items.length === 0 && p.page > 1) {
|
||||
const prev = await api.listKbDocuments(kbId, p.page - 1, DOC_PAGE_SIZE);
|
||||
setDocs(prev.items);
|
||||
setDocTotal(prev.total);
|
||||
setDocPage(prev.page);
|
||||
docPageRef.current = prev.page;
|
||||
return;
|
||||
}
|
||||
setDocs(p.items);
|
||||
setDocTotal(p.total);
|
||||
setDocPage(p.page);
|
||||
docPageRef.current = p.page;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshKbs().catch((e) => setMsg(String(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
selectedIdRef.current = selectedId;
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
docPageRef.current = docPage;
|
||||
}, [docPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setDocs([]);
|
||||
setDocTotal(0);
|
||||
setDocPage(1);
|
||||
docPageRef.current = 1;
|
||||
setSources([]);
|
||||
setSearchResults(null);
|
||||
setSearchTotal(0);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.listKbDocuments(selectedId)
|
||||
.then(setDocs)
|
||||
.catch((e) => setMsg(`加载文档失败:${String(e)}`));
|
||||
api
|
||||
.kbListSources(selectedId)
|
||||
.then(setSources)
|
||||
.catch((e) => setMsg(`加载目录来源失败:${String(e)}`));
|
||||
loadDocPage(selectedId, 1).catch((e) => setMsg(`加载文档失败:${String(e)}`));
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
const un = onEvent<KbScanProgressEvent>("kb://scan-progress", (e) => {
|
||||
setScanProgress(e);
|
||||
setScanning(e.status !== "done");
|
||||
if (e.status === "done") {
|
||||
refreshKbs().catch(() => {});
|
||||
if (selectedIdRef.current === e.kb_id) {
|
||||
api
|
||||
.kbListSources(e.kb_id)
|
||||
.then(setSources)
|
||||
.catch(() => {});
|
||||
loadDocPage(e.kb_id, docPageRef.current).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
un.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSaveKb(input: KnowledgeBaseInput) {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -121,7 +179,11 @@ export default function KnowledgeBasePage() {
|
||||
}
|
||||
|
||||
async function handleDeleteKb(kb: KnowledgeBase) {
|
||||
if (!confirm(`确定删除知识库「${kb.name}」吗?将同时删除其中 ${kb.doc_count} 个文档与 ${kb.chunk_count} 个分块。`)) {
|
||||
if (
|
||||
!confirm(
|
||||
`确定删除知识库「${kb.name}」吗?将同时删除其中 ${kb.doc_count} 个文档与 ${kb.chunk_count} 个分块。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -147,10 +209,14 @@ export default function KnowledgeBasePage() {
|
||||
const okCount = results.filter((r) => r.ok).length;
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
setUploadMsg(
|
||||
`导入完成:成功 ${okCount} 个${failed.length > 0 ? `,失败 ${failed.length} 个(${failed.map((f) => `${f.name}: ${f.error}`).join(";")})` : ""}`,
|
||||
`导入完成:成功 ${okCount} 个${
|
||||
failed.length > 0
|
||||
? `,失败 ${failed.length} 个(${failed.map((f) => `${f.name}: ${f.error}`).join(";")})`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
await refreshKbs();
|
||||
setDocs(await api.listKbDocuments(selected.id));
|
||||
await loadDocPage(selected.id, docPageRef.current);
|
||||
} catch (e) {
|
||||
setUploadMsg(`导入失败:${String(e)}`);
|
||||
} finally {
|
||||
@@ -165,7 +231,7 @@ export default function KnowledgeBasePage() {
|
||||
const n = await api.kbRechunkDocument(doc.id);
|
||||
setMsg(`已重新切分为 ${n} 个分块`);
|
||||
if (selected) {
|
||||
setDocs(await api.listKbDocuments(selected.id));
|
||||
await loadDocPage(selected.id, docPageRef.current);
|
||||
await refreshKbs();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -179,7 +245,7 @@ export default function KnowledgeBasePage() {
|
||||
await api.removeKbDocument(doc.id);
|
||||
setMsg("文档已删除");
|
||||
if (selected) {
|
||||
setDocs(await api.listKbDocuments(selected.id));
|
||||
await loadDocPage(selected.id, docPageRef.current);
|
||||
await refreshKbs();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -187,11 +253,14 @@ export default function KnowledgeBasePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
async function handleSearch(page = 1) {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
setSearchResults(await api.kbSearch(selectedId, query.trim(), 20));
|
||||
const p = await api.kbSearch(selectedId, query.trim(), page, SEARCH_PAGE_SIZE);
|
||||
setSearchResults(p.items);
|
||||
setSearchTotal(p.total);
|
||||
setSearchPage(p.page);
|
||||
} catch (e) {
|
||||
setMsg(`检索失败:${String(e)}`);
|
||||
} finally {
|
||||
@@ -199,42 +268,23 @@ export default function KnowledgeBasePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSourcesAndDocs() {
|
||||
if (!selected) return;
|
||||
const [d, s] = await Promise.all([
|
||||
api.listKbDocuments(selected.id),
|
||||
api.kbListSources(selected.id),
|
||||
]);
|
||||
setDocs(d);
|
||||
setSources(s);
|
||||
await refreshKbs();
|
||||
}
|
||||
|
||||
function formatScanMsg(label: string, r: KbSourceScanResult) {
|
||||
const errPart =
|
||||
r.errors.length > 0
|
||||
? `,${r.errors.length} 个错误(${r.errors
|
||||
.slice(0, 3)
|
||||
.join(";")}${r.errors.length > 3 ? "…" : ""})`
|
||||
: "";
|
||||
return `${label}完成:导入 ${r.imported} 个,忽略 ${r.ignored} 个${errPart}`;
|
||||
}
|
||||
|
||||
async function handleAddSource(path: string, extensions: string, recursive: boolean) {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setSourceMsg(null);
|
||||
setScanProgress(null);
|
||||
setScanning(true);
|
||||
try {
|
||||
const r = await api.kbAddSource({
|
||||
await api.kbAddSource({
|
||||
kb_id: selected.id,
|
||||
path,
|
||||
extensions,
|
||||
recursive,
|
||||
});
|
||||
await refreshSourcesAndDocs();
|
||||
setSourceMsg(formatScanMsg("添加目录", r));
|
||||
setSourceMsg("开始扫描目录,请稍候…");
|
||||
} catch (e) {
|
||||
setSourceMsg(`添加目录失败:${String(e)}`);
|
||||
setScanning(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setSourceModal(false);
|
||||
@@ -245,12 +295,13 @@ export default function KnowledgeBasePage() {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setSourceMsg(null);
|
||||
setScanProgress(null);
|
||||
setScanning(true);
|
||||
try {
|
||||
const r = await api.kbScanSources(selected.id);
|
||||
await refreshSourcesAndDocs();
|
||||
setSourceMsg(formatScanMsg("重新扫描", r));
|
||||
await api.kbScanSources(selected.id);
|
||||
} catch (e) {
|
||||
setSourceMsg(`重新扫描失败:${String(e)}`);
|
||||
setScanning(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -262,7 +313,9 @@ export default function KnowledgeBasePage() {
|
||||
const n = await api.kbRemoveSource(src.id);
|
||||
setSourceMsg(`已删除目录来源及其 ${n} 个文档`);
|
||||
if (selected) {
|
||||
await refreshSourcesAndDocs();
|
||||
setSources(await api.kbListSources(selected.id));
|
||||
await loadDocPage(selected.id, docPageRef.current);
|
||||
await refreshKbs();
|
||||
}
|
||||
} catch (e) {
|
||||
setSourceMsg(`删除失败:${String(e)}`);
|
||||
@@ -388,41 +441,6 @@ export default function KnowledgeBasePage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 检索 */}
|
||||
<section className="mb-6 rounded-xl border border-border bg-panel p-4">
|
||||
<div className="mb-2 text-xs uppercase text-slate-500">检索知识库</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={query}
|
||||
placeholder="输入关键词,检索全部文档分块(支持中文)"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn-primary shrink-0"
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !query.trim()}
|
||||
>
|
||||
<Icon name="search" className="mr-1 inline h-3.5 w-3.5" />
|
||||
{searching ? "检索中…" : "检索"}
|
||||
</button>
|
||||
</div>
|
||||
{searchResults ? (
|
||||
searchResults.length === 0 ? (
|
||||
<div className="mt-3 text-xs text-slate-500">没有匹配的内容</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{searchResults.map((hit) => (
|
||||
<SearchHitRow key={hit.chunk_id} hit={hit} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* 目录来源 */}
|
||||
<section className="mb-6 rounded-xl border border-border bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
@@ -433,7 +451,7 @@ export default function KnowledgeBasePage() {
|
||||
<button
|
||||
className="btn-secondary !px-2.5 !py-1 text-xs"
|
||||
onClick={handleScanAll}
|
||||
disabled={busy || sources.length === 0}
|
||||
disabled={busy || scanning || sources.length === 0}
|
||||
>
|
||||
<Icon name="refresh" className="mr-1 inline h-3 w-3" />
|
||||
重新扫描
|
||||
@@ -441,6 +459,7 @@ export default function KnowledgeBasePage() {
|
||||
<button
|
||||
className="btn-primary !px-2.5 !py-1 text-xs"
|
||||
onClick={() => setSourceModal(true)}
|
||||
disabled={scanning}
|
||||
>
|
||||
+ 添加目录
|
||||
</button>
|
||||
@@ -454,6 +473,40 @@ export default function KnowledgeBasePage() {
|
||||
{sourceMsg}
|
||||
</div>
|
||||
) : null}
|
||||
{scanProgress ? (
|
||||
<div className="mb-3 rounded-lg border border-border bg-panel-2/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-slate-300">
|
||||
<span className="min-w-0 flex-1 truncate">{scanProgress.source_path}</span>
|
||||
<span className="shrink-0">
|
||||
{scanning
|
||||
? `处理中 ${scanProgress.current}/${scanProgress.total}`
|
||||
: "扫描完成"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-panel">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-accent to-accent-2 transition-all duration-200"
|
||||
style={{
|
||||
width: `${
|
||||
scanProgress.total > 0
|
||||
? (scanProgress.current / scanProgress.total) * 100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[11px] text-slate-400">
|
||||
处理 {scanProgress.current} / {scanProgress.total} 个文件 · 成功{" "}
|
||||
{scanProgress.imported} · 失败 {scanProgress.failed} · 忽略{" "}
|
||||
{scanProgress.ignored} · 分块 {scanProgress.chunks}
|
||||
{scanProgress.errors.length > 0
|
||||
? `(${scanProgress.errors[0]}${
|
||||
scanProgress.errors.length > 1 ? " 等" : ""
|
||||
})`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{sources.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-panel-2/40 px-3 py-6 text-center text-xs text-slate-500">
|
||||
还没有目录来源,点击「添加目录」批量导入文件夹中的文档
|
||||
@@ -499,30 +552,84 @@ export default function KnowledgeBasePage() {
|
||||
</section>
|
||||
|
||||
{/* 文档列表 */}
|
||||
<section>
|
||||
<section className="mb-6">
|
||||
<div className="mb-2 text-xs uppercase text-slate-500">
|
||||
文档({docs.length})
|
||||
文档(共 {docTotal} 个)
|
||||
</div>
|
||||
{docs.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border bg-panel/50 px-4 py-10 text-center text-sm text-slate-500">
|
||||
还没有文档,点击右上角「上传文档」导入 txt / md / PDF 等文件
|
||||
还没有文档,可在上方「目录来源」批量导入,或点击右上角「上传文档」导入 txt / md / PDF 等文件
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{docs.map((doc) => (
|
||||
<DocRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onPreview={() => {
|
||||
api.getKbDocument(doc.id).then(setPreview).catch((e) => setMsg(String(e)));
|
||||
}}
|
||||
onRechunk={() => handleRechunk(doc)}
|
||||
onDelete={() => handleDeleteDoc(doc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{docs.map((doc) => (
|
||||
<DocRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onPreview={() => {
|
||||
api
|
||||
.getKbDocument(doc.id)
|
||||
.then(setPreview)
|
||||
.catch((e) => setMsg(String(e)));
|
||||
}}
|
||||
onRechunk={() => handleRechunk(doc)}
|
||||
onDelete={() => handleDeleteDoc(doc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pager
|
||||
page={docPage}
|
||||
total={docTotal}
|
||||
pageSize={DOC_PAGE_SIZE}
|
||||
onChange={(p) => selected && loadDocPage(selected.id, p)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 检索 */}
|
||||
<section className="rounded-xl border border-border bg-panel p-4">
|
||||
<div className="mb-2 text-xs uppercase text-slate-500">检索知识库</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={query}
|
||||
placeholder="输入关键词,检索全部文档分块(支持中文)"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn-primary shrink-0"
|
||||
onClick={() => handleSearch(1)}
|
||||
disabled={searching || !query.trim()}
|
||||
>
|
||||
<Icon name="search" className="mr-1 inline h-3.5 w-3.5" />
|
||||
{searching ? "检索中…" : "检索"}
|
||||
</button>
|
||||
</div>
|
||||
{searchResults ? (
|
||||
searchResults.length === 0 ? (
|
||||
<div className="mt-3 text-xs text-slate-500">没有匹配的内容</div>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<div className="space-y-2">
|
||||
{searchResults.map((hit) => (
|
||||
<SearchHitRow key={hit.chunk_id} hit={hit} />
|
||||
))}
|
||||
</div>
|
||||
<Pager
|
||||
page={searchPage}
|
||||
total={searchTotal}
|
||||
pageSize={SEARCH_PAGE_SIZE}
|
||||
onChange={(p) => handleSearch(p)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@@ -571,6 +678,41 @@ export default function KnowledgeBasePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
page,
|
||||
total,
|
||||
pageSize,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-center gap-3 text-xs text-slate-400">
|
||||
<button
|
||||
className="rounded border border-border bg-panel-2 px-2.5 py-1 hover:text-slate-200 disabled:opacity-40"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
第 {page} / {pages} 页 · 共 {total} 条
|
||||
</span>
|
||||
<button
|
||||
className="rounded border border-border bg-panel-2 px-2.5 py-1 hover:text-slate-200 disabled:opacity-40"
|
||||
disabled={page >= pages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchHitRow({ hit }: { hit: KbSearchHit }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
@@ -845,10 +987,7 @@ function SourceModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
checked={recursive}
|
||||
onChange={(v) => setRecursive(v)}
|
||||
/>
|
||||
<Toggle checked={recursive} onChange={(v) => setRecursive(v)} />
|
||||
<span className="text-xs text-slate-400">
|
||||
{recursive ? "递归扫描所有子目录" : "仅扫描当前目录"}
|
||||
</span>
|
||||
@@ -866,7 +1005,7 @@ function SourceModal({
|
||||
disabled={busy || !valid}
|
||||
onClick={() => onSave(path.trim(), extensions.trim(), recursive)}
|
||||
>
|
||||
{busy ? "扫描中…" : "添加并扫描"}
|
||||
{busy ? "添加中…" : "添加并扫描"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user