feat: 知识库一键后台保存 + 对话置顶仅首页 + 排行榜页面增强

- 知识库「保存」改为一键触发,无需填表;后端经 BackgroundTasks 异步沉淀到 wiki,点击立即返回 {queued:true}
- 对话列表置顶分区仅在第一页展示,翻页后不再重复顶置
- 公开/管理员排行榜页面重构与维度增强;排行榜页改为整页统一滚动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-08 19:59:55 +08:00
1 parent 24db448cfd
commit 92a46003f8
8 files changed
+854 -304

No files matched your search

@@ -1,27 +1,9 @@
"use client";
import { BookMarked } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import { useThread } from "./messages/context";
@@ -29,130 +11,37 @@ import { Tooltip } from "./tooltip";
export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
const { thread } = useThread();
const navigate = useNavigate();
const { ingestThread } = useKnowledgeActions();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [status, setStatus] = useState<"approved" | "draft">("approved");
const [mode, setMode] = useState<"summary" | "full">("summary");
const [includeSources, setIncludeSources] = useState(true);
if (thread.messages.length === 0) {
return null;
}
// One click → fire-and-forget. The backend sediments the thread into the
// wiki asynchronously (title/tags auto-generated), so the click never blocks.
const handleSave = async () => {
try {
const result = await ingestThread.mutateAsync({
await ingestThread.mutateAsync({
threadId,
payload: {
title: title.trim() || null,
tags: tags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status,
mode,
include_sources: includeSources,
},
payload: { background: true },
});
toast.success("已保存到知识库", {
action: {
label: "查看",
onClick: () =>
navigate(`/page/workspace/knowledge/${result.note.id}`),
},
});
setOpen(false);
setTitle("");
setTags("");
toast.success("已加入知识库保存队列,正在后台整理…");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<>
<Tooltip content="保存到知识库">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => setOpen(true)}
>
<BookMarked />
保存
</Button>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>保存到知识库</DialogTitle>
<DialogDescription>
将本次对话沉淀为 obsidian-wiki 兼容的知识笔记。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder="标题(留空自动生成)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="标签,逗号分隔"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<div className="flex items-center gap-2">
<Select
value={status}
onValueChange={(v) => setStatus(v as "approved" | "draft")}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="approved">正式</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
</SelectContent>
</Select>
<Select
value={mode}
onValueChange={(v) => setMode(v as "summary" | "full")}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="summary">摘要</SelectItem>
<SelectItem value="full">完整</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSources}
onChange={(e) => setIncludeSources(e.target.checked)}
className="size-4"
/>
包含搜索 / 工具来源
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button
onClick={() => void handleSave()}
disabled={ingestThread.isPending}
>
{ingestThread.isPending ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
<Tooltip content="保存到知识库(后台异步整理)">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => void handleSave()}
disabled={ingestThread.isPending}
>
<BookMarked />
{ingestThread.isPending ? "保存中…" : "保存"}
</Button>
</Tooltip>
);
}
+5 -1
View File
@@ -102,7 +102,11 @@ export async function searchKnowledge(query: string, limit = 8) {
}
export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) {
return parseResponse<{ note: KnowledgeNote; created: boolean }>(
return parseResponse<{
note?: KnowledgeNote;
created?: boolean;
queued?: boolean;
}>(
await apiFetch(`${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
+2
View File
@@ -70,6 +70,8 @@ export interface IngestThreadPayload {
status?: KnowledgeStatus;
tags?: string[];
include_sources?: boolean;
/** When true the backend sediments asynchronously and returns immediately. */
background?: boolean;
}
export interface BatchIngestPayload {
@@ -1229,6 +1229,8 @@ export default function AdminUserLeaderboardPage() {
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
{/* 整页统一滚动:标题与操作工具条不再固定置顶,随内容一起滚动,避免可视区域被压缩 */}
<ScrollArea className="min-h-0 flex-1">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-col gap-4">
{/* 标题维度:仅承载页面标题与说明 */}
@@ -1448,7 +1450,6 @@ export default function AdminUserLeaderboardPage() {
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6">
{isLoading && (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368] dark:text-slate-400">
+4 -3
View File
@@ -76,8 +76,9 @@ export default function ChatsPage() {
});
}, [threads, search, onlyFavorites]);
// Pinned conversations are surfaced in their own section and excluded from
// the paginated list so they stay visible regardless of the current page.
// Pinned conversations are surfaced in their own section on the first page
// only, and excluded from the paginated list (so they don't repeat on every
// page as the user pages through history).
const pinnedThreads = useMemo(
() => filteredThreads.filter(isThreadPinned),
[filteredThreads],
@@ -200,7 +201,7 @@ export default function ChatsPage() {
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{pinnedThreads.length > 0 && (
{currentPage === 1 && pinnedThreads.length > 0 && (
<>
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
{t.chats.pinnedSection}
File diff suppressed because it is too large. Load diff