feat: RAG inline citations, source highlighting, and admin panel

- Increase RAG retrieval from 5 to 50 docs with relevance threshold (0.15)
- LLM inline citations with [来源N] format and reference list
- Clickable citation links scroll to source cards with highlight animation
- Source previews with full chunk text and answer-matched highlighting
- Message-scoped source IDs to fix cross-round citation targeting
- Admin panel pages (knowledge, users, forum, course, settings)
- Add rehype-raw dependency for HTML-in-markdown rendering

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:51:28 +08:00
parent 5d42a0573a
commit abcced35b8
23 changed files with 2249 additions and 112 deletions
+1
View File
@@ -57,6 +57,7 @@
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.3",
+50
View File
@@ -131,6 +131,9 @@ importers:
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -1260,6 +1263,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -2099,9 +2103,15 @@ packages:
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
@@ -2127,6 +2137,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -3000,6 +3013,9 @@ packages:
rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -5450,6 +5466,22 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
hast-util-raw@9.1.0:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.1
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
parse5: 7.3.0
unist-util-position: 5.0.0
unist-util-visit: 5.0.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -5470,6 +5502,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
@@ -5505,6 +5547,8 @@ snapshots:
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -6552,6 +6596,12 @@ snapshots:
unist-util-visit-parents: 6.0.1
vfile: 6.0.3
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useState, useEffect } from "react";
import { BookOpen } from "lucide-react";
import { courseContentAPI } from "@/lib/api";
interface Subsection {
id: number;
subsection_number: number;
title: string;
display_order: number;
}
interface Section {
id: number;
section_number: number;
title: string;
display_order: number;
subsections: Subsection[];
}
interface Chapter {
id: number;
chapter_number: number;
title: string;
display_order: number;
sections: Section[];
}
export default function CourseAdminPage() {
const [chapters, setChapters] = useState<Chapter[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
useEffect(() => {
courseContentAPI.getCourseContent()
.then((data) => setChapters(data.chapters || []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const toggle = (key: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
if (loading) {
return <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
const totalSections = chapters.reduce((a, c) => a + c.sections.length, 0);
const totalSubsections = chapters.reduce(
(a, c) => a + c.sections.reduce((b, s) => b + s.subsections.length, 0),
0
);
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<BookOpen className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="rounded-xl border p-4 text-center">
<div className="text-2xl font-semibold font-mono tabular-nums">{chapters.length}</div>
<div className="text-xs text-muted-foreground mt-1"></div>
</div>
<div className="rounded-xl border p-4 text-center">
<div className="text-2xl font-semibold font-mono tabular-nums">{totalSections}</div>
<div className="text-xs text-muted-foreground mt-1"></div>
</div>
<div className="rounded-xl border p-4 text-center">
<div className="text-2xl font-semibold font-mono tabular-nums">{totalSubsections}</div>
<div className="text-xs text-muted-foreground mt-1"></div>
</div>
</div>
{chapters.length === 0 ? (
<div className="rounded-xl border py-12 text-center text-muted-foreground text-sm">
</div>
) : (
<div className="space-y-2">
{chapters.map((chapter) => {
const chKey = `ch-${chapter.id}`;
return (
<div key={chapter.id} className="rounded-xl border overflow-hidden">
<button
onClick={() => toggle(chKey)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors text-left"
>
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-muted-foreground w-8">
{chapter.chapter_number}
</span>
<span className="font-medium text-sm">{chapter.title}</span>
<span className="text-xs text-muted-foreground">
{chapter.sections.length}
</span>
</div>
<svg
className={`w-4 h-4 text-muted-foreground transition-transform ${expanded.has(chKey) ? "rotate-90" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{expanded.has(chKey) && (
<div className="border-t">
{chapter.sections.map((section) => {
const secKey = `sec-${section.id}`;
return (
<div key={section.id} className="border-b last:border-0">
<button
onClick={() => toggle(secKey)}
className="w-full flex items-center justify-between px-4 py-2.5 pl-10 hover:bg-muted/20 transition-colors text-left"
>
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-muted-foreground w-8">
{section.section_number}
</span>
<span className="text-sm">{section.title}</span>
<span className="text-xs text-muted-foreground">
{section.subsections.length}
</span>
</div>
<svg
className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${expanded.has(secKey) ? "rotate-90" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{expanded.has(secKey) && section.subsections.length > 0 && (
<div className="border-t bg-muted/10">
{section.subsections.map((sub) => (
<div
key={sub.id}
className="flex items-center gap-3 px-4 py-2 pl-16 text-sm text-muted-foreground"
>
<span className="text-xs font-mono w-8">{sub.subsection_number}</span>
<span>{sub.title}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useState, useEffect } from "react";
import { adminAPI } from "@/lib/api";
import { format } from "date-fns";
import { MessageSquare, Plus, Trash2, RefreshCw, Pencil } from "lucide-react";
interface AdminCategory {
id: number;
slug: string;
name: string;
description: string | null;
post_count: number;
}
interface AdminPost {
id: number;
title: string;
author_name: string;
category_name: string;
reply_count: number;
created_at: string;
}
export default function ForumAdminPage() {
const [tab, setTab] = useState<"categories" | "posts">("categories");
const [categories, setCategories] = useState<AdminCategory[]>([]);
const [posts, setPosts] = useState<AdminPost[]>([]);
const [loading, setLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<{ type: "cat" | "post"; id: number } | null>(null);
// Category form
const [showCatForm, setShowCatForm] = useState(false);
const [editingCat, setEditingCat] = useState<AdminCategory | null>(null);
const [catForm, setCatForm] = useState({ name: "", slug: "", description: "" });
const loadCategories = () => {
adminAPI.listForumCategories()
.then(setCategories)
.catch(console.error);
};
const loadPosts = () => {
adminAPI.listForumPosts()
.then(setPosts)
.catch(console.error);
};
useEffect(() => {
setLoading(true);
Promise.all([loadCategories(), loadPosts()]).finally(() => setLoading(false));
}, []);
const handleSaveCategory = async () => {
try {
if (editingCat) {
await adminAPI.updateForumCategory(editingCat.id, catForm);
} else {
await adminAPI.createForumCategory(catForm);
}
setShowCatForm(false);
setEditingCat(null);
setCatForm({ name: "", slug: "", description: "" });
loadCategories();
} catch (e) { console.error(e); }
};
const handleDeleteCategory = async (id: number) => {
try {
await adminAPI.deleteForumCategory(id);
setConfirmDelete(null);
loadCategories();
} catch (e) { console.error(e); }
};
const handleDeletePost = async (id: number) => {
try {
await adminAPI.deleteForumPost(id);
setConfirmDelete(null);
loadPosts();
} catch (e) { console.error(e); }
};
const startEditCat = (cat: AdminCategory) => {
setEditingCat(cat);
setCatForm({ name: cat.name, slug: cat.slug, description: cat.description || "" });
setShowCatForm(true);
};
if (loading) {
return <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<MessageSquare className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setTab("categories")}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
tab === "categories" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
}`}
>
</button>
<button
onClick={() => setTab("posts")}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
tab === "posts" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
}`}
>
</button>
</div>
</div>
{tab === "categories" && (
<div className="space-y-4">
{!showCatForm ? (
<button
onClick={() => { setEditingCat(null); setCatForm({ name: "", slug: "", description: "" }); setShowCatForm(true); }}
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<Plus className="w-4 h-4" />
</button>
) : (
<div className="rounded-xl border p-4 space-y-3">
<h3 className="text-sm font-medium">{editingCat ? "编辑分类" : "新增分类"}</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={catForm.name}
onChange={(e) => setCatForm((f) => ({ ...f, name: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Slug</label>
<input
value={catForm.slug}
onChange={(e) => setCatForm((f) => ({ ...f, slug: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="col-span-2">
<label className="text-xs text-muted-foreground"></label>
<input
value={catForm.description}
onChange={(e) => setCatForm((f) => ({ ...f, description: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
<div className="flex gap-2">
<button onClick={handleSaveCategory} className="px-4 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90">
</button>
<button onClick={() => { setShowCatForm(false); setEditingCat(null); }} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-muted">
</button>
</div>
</div>
)}
<div className="rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium">Slug</th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{categories.map((cat) => (
<tr key={cat.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 font-medium">{cat.name}</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{cat.slug}</td>
<td className="px-4 py-3 text-muted-foreground text-xs max-w-40 truncate">{cat.description || "—"}</td>
<td className="px-4 py-3 text-center font-mono tabular-nums">{cat.post_count}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-2">
<button onClick={() => startEditCat(cat)} className="text-muted-foreground hover:text-foreground">
<Pencil className="w-3.5 h-3.5" />
</button>
{confirmDelete?.type === "cat" && confirmDelete.id === cat.id ? (
<div className="flex items-center gap-1">
<button onClick={() => handleDeleteCategory(cat.id)} className="text-xs px-1.5 py-0.5 bg-red-600 text-white rounded"></button>
<button onClick={() => setConfirmDelete(null)} className="text-xs px-1.5 py-0.5 border rounded"></button>
</div>
) : (
<button onClick={() => setConfirmDelete({ type: "cat", id: cat.id })} className="text-red-500 hover:text-red-700">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tab === "posts" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"> {posts.length} </span>
<button onClick={loadPosts} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<div className="rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{posts.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground"></td></tr>
) : posts.map((p) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 font-medium max-w-64 truncate">{p.title}</td>
<td className="px-4 py-3 text-muted-foreground">{p.author_name}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{p.category_name}</td>
<td className="px-4 py-3 text-center font-mono tabular-nums">{p.reply_count}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{format(new Date(p.created_at), "yyyy/M/d HH:mm")}
</td>
<td className="px-4 py-3 text-center">
{confirmDelete?.type === "post" && confirmDelete.id === p.id ? (
<div className="flex items-center justify-center gap-1">
<button onClick={() => handleDeletePost(p.id)} className="text-xs px-2 py-1 bg-red-600 text-white rounded"></button>
<button onClick={() => setConfirmDelete(null)} className="text-xs px-2 py-1 border rounded"></button>
</div>
) : (
<button onClick={() => setConfirmDelete({ type: "post", id: p.id })} className="text-red-500 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useState, useEffect } from "react";
import { adminAPI } from "@/lib/api";
import { format } from "date-fns";
import { Database, Trash2, RefreshCw } from "lucide-react";
interface AdminKB {
id: number;
name: string;
description: string | null;
owner_name: string;
is_system: boolean;
document_count: number;
chunk_count: number;
created_at: string;
}
export default function KnowledgeAdminPage() {
const [kbs, setKbs] = useState<AdminKB[]>([]);
const [loading, setLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const load = () => {
setLoading(true);
adminAPI.listKnowledgeBases()
.then(setKbs)
.catch(console.error)
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const handleDelete = async (kbId: number) => {
try {
await adminAPI.deleteKnowledgeBase(kbId);
setConfirmDelete(null);
setKbs((prev) => prev.filter((kb) => kb.id !== kbId));
} catch (e) { console.error(e); }
};
if (loading) {
return <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-muted-foreground"> {kbs.length} </span>
</div>
<button
onClick={load}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{kbs.length === 0 ? (
<div className="rounded-xl border py-12 text-center text-muted-foreground text-sm">
</div>
) : (
<div className="rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{kbs.map((kb) => (
<tr key={kb.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 font-medium">{kb.name}</td>
<td className="px-4 py-3 text-muted-foreground text-xs max-w-48 truncate">
{kb.description || "—"}
</td>
<td className="px-4 py-3 text-center text-muted-foreground">{kb.owner_name}</td>
<td className="px-4 py-3 text-center">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
kb.is_system
? "bg-blue-100 text-blue-700"
: "bg-slate-100 text-slate-600"
}`}
>
{kb.is_system ? "系统" : "用户"}
</span>
</td>
<td className="px-4 py-3 text-center font-mono tabular-nums">{kb.document_count}</td>
<td className="px-4 py-3 text-center font-mono tabular-nums">{kb.chunk_count}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{format(new Date(kb.created_at), "yyyy/M/d HH:mm")}
</td>
<td className="px-4 py-3 text-center">
{confirmDelete === kb.id ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleDelete(kb.id)}
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
>
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 border rounded hover:bg-accent"
>
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(kb.id)}
className="text-red-500 hover:text-red-700 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useAuthStore } from "@/store/auth";
import Link from "next/link";
import {
LayoutDashboard,
Users,
Database,
MessageSquare,
BookOpen,
Settings,
Shield,
} from "lucide-react";
import type { ReactNode } from "react";
const ADMIN_NAV = [
{ href: "/admin", label: "数据概览", icon: LayoutDashboard, exact: true },
{ href: "/admin/users", label: "用户管理", icon: Users },
{ href: "/admin/knowledge", label: "知识库管理", icon: Database },
{ href: "/admin/forum", label: "论坛管理", icon: MessageSquare },
{ href: "/admin/course", label: "课程内容", icon: BookOpen },
{ href: "/admin/settings", label: "系统设置", icon: Settings },
];
export default function AdminLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { user, isAuthenticated } = useAuthStore();
if (!isAuthenticated || !user?.is_superuser) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4">
<Shield className="w-16 h-16 mx-auto text-muted-foreground" />
<h2 className="text-xl font-semibold text-foreground"></h2>
<p className="text-sm text-muted-foreground">访</p>
<button
onClick={() => router.push("/")}
className="text-primary hover:underline text-sm"
>
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-[calc(100vh-4rem)]">
{/* Sidebar */}
<aside className="w-56 shrink-0 border-r bg-background hidden md:block">
<div className="p-4 border-b">
<h2 className="text-sm font-semibold text-foreground flex items-center gap-2">
<Shield className="w-4 h-4 text-primary" />
</h2>
</div>
<nav className="p-2 space-y-0.5">
{ADMIN_NAV.map((item) => {
const active = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`
flex items-center gap-2.5 px-3 py-2 text-sm rounded-lg transition-colors
${active
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}
`}
>
<item.icon className="w-4 h-4" />
{item.label}
</Link>
);
})}
</nav>
</aside>
{/* Mobile nav */}
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-background border-t z-50">
<div className="flex items-center justify-around py-2">
{ADMIN_NAV.map((item) => {
const active = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-0.5 px-2 py-1 text-[10px] ${
active ? "text-primary" : "text-muted-foreground"
}`}
>
<item.icon className="w-4 h-4" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</div>
{/* Main content */}
<main className="flex-1 overflow-auto pb-16 md:pb-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</div>
</main>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useState, useEffect } from "react";
import { adminAPI } from "@/lib/api";
import { Users, MessageSquare, FileText, TrendingUp, Image, Activity } from "lucide-react";
interface DashboardStats {
total_users: number;
active_users_7d: number;
total_sessions: number;
total_messages: number;
total_documents: number;
total_knowledge_bases: number;
total_forum_posts: number;
total_forum_replies: number;
total_generated_images: number;
}
function TrendChart({ title, icon: Icon, data, color }: {
title: string;
icon: React.ElementType;
data: { date: string; count: number }[];
color: string;
}) {
return (
<div className="rounded-xl border p-5 space-y-3">
<h3 className="text-sm font-medium flex items-center gap-2">
<Icon className="w-4 h-4" />{title}
</h3>
<div className="h-40 flex items-end gap-1">
{data.length === 0 ? (
<div className="w-full text-center text-xs text-muted-foreground py-8"></div>
) : data.map((d) => {
const max = Math.max(...data.map((x) => x.count), 1);
const h = Math.max((d.count / max) * 100, 2);
return (
<div key={d.date} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[9px] text-muted-foreground tabular-nums">{d.count || ""}</span>
<div className="w-full rounded-t" style={{ height: `${h}%`, backgroundColor: color, opacity: 0.35 }} title={`${d.date}: ${d.count}`} />
<span className="text-[9px] text-muted-foreground">{d.date.slice(5)}</span>
</div>
);
})}
</div>
</div>
);
}
export default function AdminPage() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [userTrends, setUserTrends] = useState<{ date: string; count: number }[]>([]);
const [msgTrends, setMsgTrends] = useState<{ date: string; count: number }[]>([]);
const [systemStatus, setSystemStatus] = useState<{
database: { status: string };
vector_store: { status: string; vector_count: number };
llm_model: string;
embedding_model: string;
} | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.allSettled([
adminAPI.getDashboard(),
adminAPI.getUserTrends(14),
adminAPI.getMessageTrends(14),
adminAPI.getSystemStatus(),
]).then(([dashboard, trendsU, trendsM, status]) => {
if (dashboard.status === "fulfilled") setStats(dashboard.value);
if (trendsU.status === "fulfilled") setUserTrends(trendsU.value);
if (trendsM.status === "fulfilled") setMsgTrends(trendsM.value);
if (status.status === "fulfilled") setSystemStatus(status.value);
setLoading(false);
});
}, []);
if (loading) {
return <div className="py-12 text-center text-muted-foreground text-sm">...</div>;
}
const cards = [
{ label: "注册用户", value: stats?.total_users ?? 0, icon: Users, sub: `${stats?.active_users_7d ?? 0} 人近7天活跃` },
{ label: "对话会话", value: stats?.total_sessions ?? 0, icon: MessageSquare, sub: `${stats?.total_messages ?? 0} 条消息` },
{ label: "知识文档", value: stats?.total_documents ?? 0, icon: FileText, sub: `${stats?.total_knowledge_bases ?? 0} 个知识库` },
{ label: "论坛帖子", value: stats?.total_forum_posts ?? 0, icon: TrendingUp, sub: `${stats?.total_forum_replies ?? 0} 条回复` },
{ label: "生成图像", value: stats?.total_generated_images ?? 0, icon: Image, sub: "" },
];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{cards.map((c) => (
<div key={c.label} className="rounded-xl border p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{c.label}</span>
<c.icon className="w-4 h-4 text-muted-foreground" />
</div>
<div className="text-2xl font-semibold font-mono tabular-nums">{c.value.toLocaleString()}</div>
{c.sub && <div className="text-[11px] text-muted-foreground">{c.sub}</div>}
</div>
))}
</div>
<div className="grid md:grid-cols-2 gap-6">
<TrendChart title="近14天用户注册" icon={Users} data={userTrends} color="#2563eb" />
<TrendChart title="近14天消息数" icon={MessageSquare} data={msgTrends} color="#16a34a" />
</div>
{systemStatus && (
<div className="rounded-xl border p-5 space-y-3">
<h3 className="text-sm font-medium flex items-center gap-2">
<Activity className="w-4 h-4" />
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "数据库", ok: systemStatus.database.status === "ok" },
{ label: "向量库", ok: systemStatus.vector_store.status === "ok", sub: `${systemStatus.vector_store.vector_count} 向量` },
{ label: "LLM 模型", ok: true, sub: systemStatus.llm_model },
{ label: "嵌入模型", ok: true, sub: systemStatus.embedding_model },
].map((s) => (
<div key={s.label} className="flex items-center gap-2 text-sm">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: s.ok ? "#22c55e" : "#ef4444" }} />
<span>{s.label}</span>
{s.sub && <span className="text-muted-foreground text-xs truncate">{s.sub}</span>}
</div>
))}
</div>
</div>
)}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useState, useEffect } from "react";
import { adminAPI } from "@/lib/api";
import { Settings, RefreshCw } from "lucide-react";
interface SystemStatus {
database: { status: string };
vector_store: { status: string; vector_count: number };
llm_model: string;
embedding_model: string;
}
export default function SystemSettingsPage() {
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const load = () => {
setLoading(true);
adminAPI.getSystemStatus()
.then(setStatus)
.catch(console.error)
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
if (loading) {
return <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
const statusItems = status ? [
{
label: "数据库",
ok: status.database.status === "ok",
detail: status.database.status === "ok" ? "运行正常" : "连接异常",
},
{
label: "向量存储",
ok: status.vector_store.status === "ok",
detail: `${status.vector_store.vector_count.toLocaleString()} 个向量`,
},
{
label: "LLM 模型",
ok: true,
detail: status.llm_model,
},
{
label: "嵌入模型",
ok: true,
detail: status.embedding_model,
},
] : [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
</div>
<button
onClick={load}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{/* System status */}
<div className="rounded-xl border">
<div className="px-5 py-3 border-b">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="divide-y">
{statusItems.map((s) => (
<div key={s.label} className="flex items-center justify-between px-5 py-3">
<div className="flex items-center gap-3">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: s.ok ? "#22c55e" : "#ef4444" }}
/>
<span className="text-sm">{s.label}</span>
</div>
<span className="text-sm text-muted-foreground">{s.detail}</span>
</div>
))}
</div>
</div>
{/* Runtime info */}
<div className="rounded-xl border">
<div className="px-5 py-3 border-b">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="divide-y">
<div className="flex items-center justify-between px-5 py-3">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground font-mono">1.0.0</span>
</div>
<div className="flex items-center justify-between px-5 py-3">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground">Next.js 15 + FastAPI</span>
</div>
<div className="flex items-center justify-between px-5 py-3">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground">Docker (Supervisor)</span>
</div>
<div className="flex items-center justify-between px-5 py-3">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground">ChromaDB</span>
</div>
</div>
</div>
{/* Danger zone */}
<div className="rounded-xl border border-red-200">
<div className="px-5 py-3 border-b border-red-200">
<h3 className="text-sm font-medium text-red-600"></h3>
</div>
<div className="px-5 py-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm"></div>
<div className="text-xs text-muted-foreground"></div>
</div>
<button
className="px-3 py-1.5 text-sm border border-red-200 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
onClick={() => alert("此功能需要通过后端命令行执行")}
>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm"></div>
<div className="text-xs text-muted-foreground"></div>
</div>
<button
className="px-3 py-1.5 text-sm border border-red-200 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
onClick={() => alert("此功能需要通过后端命令行执行")}
>
</button>
</div>
</div>
</div>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useState, useEffect } from "react";
import { adminAPI } from "@/lib/api";
import { format } from "date-fns";
import { Users, Search } from "lucide-react";
interface AdminUser {
id: number;
username: string;
email: string;
full_name: string | null;
is_active: boolean;
is_superuser: boolean;
created_at: string | null;
last_login: string | null;
session_count: number;
message_count: number;
}
export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [search, setSearch] = useState("");
const loadUsers = () => {
setLoading(true);
adminAPI.listUsers()
.then(setUsers)
.catch(console.error)
.finally(() => setLoading(false));
};
useEffect(() => { loadUsers(); }, []);
const handleToggleActive = async (userId: number) => {
try {
await adminAPI.toggleUserActive(userId);
setUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, is_active: !u.is_active } : u))
);
} catch (e) { console.error(e); }
};
const handleToggleAdmin = async (userId: number) => {
try {
await adminAPI.toggleUserAdmin(userId);
setUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, is_superuser: !u.is_superuser } : u))
);
} catch (e) { console.error(e); }
};
const handleDelete = async (userId: number) => {
try {
await adminAPI.deleteUser(userId);
setConfirmDelete(null);
setUsers((prev) => prev.filter((u) => u.id !== userId));
} catch (e) { console.error(e); }
};
const filtered = users.filter(
(u) =>
u.username.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase()) ||
(u.full_name && u.full_name.toLowerCase().includes(search.toLowerCase()))
);
if (loading) {
return <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Users className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-muted-foreground"> {users.length} </span>
</div>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索用户名、邮箱..."
className="pl-9 pr-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
<div className="rounded-xl border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"> / </th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
{search ? "无匹配用户" : "暂无用户"}
</td>
</tr>
)}
{filtered.map((u) => (
<tr key={u.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<div className="font-medium">{u.username}</div>
{u.full_name && <div className="text-xs text-muted-foreground">{u.full_name}</div>}
</td>
<td className="px-4 py-3 text-muted-foreground">{u.email}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggleActive(u.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
u.is_active
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
: "bg-red-100 text-red-700 hover:bg-red-200"
}`}
>
{u.is_active ? "正常" : "禁用"}
</button>
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggleAdmin(u.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
u.is_superuser
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
{u.is_superuser ? "管理员" : "用户"}
</button>
</td>
<td className="px-4 py-3 text-center font-mono tabular-nums text-muted-foreground">
{u.session_count} / {u.message_count}
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{u.created_at ? format(new Date(u.created_at), "yyyy/M/d HH:mm") : "—"}
</td>
<td className="px-4 py-3 text-center">
{confirmDelete === u.id ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleDelete(u.id)}
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
>
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 border rounded hover:bg-accent"
>
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(u.id)}
className="text-xs text-red-500 hover:text-red-700 transition-colors"
>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
+33
View File
@@ -378,6 +378,39 @@ button {
margin: 1.5em 0;
}
/* 文内引用链接样式 */
.citation-link {
display: inline-block;
padding: 0 0.125rem;
font-size: 0.7em;
font-weight: 700;
color: hsl(var(--primary));
background-color: hsl(var(--primary) / 0.08);
border-radius: 0.25rem;
cursor: pointer;
text-decoration: none;
vertical-align: super;
transition: background-color 0.15s ease;
}
.citation-link:hover {
background-color: hsl(var(--primary) / 0.18);
text-decoration: none;
}
/* 来源卡片锚点滚动偏移(避免被固定header遮挡) */
.scroll-mt-20 {
scroll-margin-top: 5rem;
}
/* 来源卡片被引用点击时的高亮动画 */
@keyframes citation-highlight {
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
50% { box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.35); }
}
.citation-highlight {
animation: citation-highlight 1.2s ease-in-out 2;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
+36 -2
View File
@@ -5,6 +5,7 @@ import { User, Bot, Copy, Edit, RotateCcw, ThumbsUp, ThumbsDown, Loader2, Brain
import { cn } from "@/lib/utils";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
import { format } from "date-fns";
@@ -96,6 +97,13 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[];
);
};
function preprocessCitations(content: string, messageId: string): string {
return content.replace(
/\[来源\s*(\d+)\]/g,
`<a class="citation-link" href="#source-${messageId}-$1" data-message-id="${messageId}" data-source-id="$1">[$1]</a>`
);
}
export default function MessageItem({ message, selectedModel }: MessageItemProps) {
const isUser = message.role === "user";
const isAssistant = message.role === "assistant";
@@ -190,7 +198,33 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
a: ({ href, children, className, ...props }: any) => {
if (className === 'citation-link') {
const sourceId = (props as any)['data-source-id'];
const messageId = (props as any)['data-message-id'];
const fullId = `source-${messageId}-${sourceId}`;
return (
<button
className="citation-link"
onClick={(e) => {
e.preventDefault();
const target = document.getElementById(fullId);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('citation-highlight');
setTimeout(() => target.classList.remove('citation-highlight'), 2500);
}
}}
title={`跳转到来源 ${sourceId}`}
>
[{sourceId}]
</button>
);
}
return <a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline" {...props}>{children}</a>;
},
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
@@ -220,13 +254,13 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
),
}}
>
{message.content}
{preprocessCitations(message.content, String(message.id))}
</ReactMarkdown>
</div>
{isAssistant && message.metadata?.sources && message.metadata.sources.length > 0 && (
<div className="mt-2.5 pt-2.5 border-t border-border/30">
<SourceReferences sources={message.metadata.sources} maxSources={3} />
<SourceReferences sources={message.metadata.sources} answerContent={message.content} messageId={String(message.id)} />
</div>
)}
</div>
+251 -89
View File
@@ -1,10 +1,13 @@
"use client";
import { FileText, ExternalLink, Star, Database, Globe } from "lucide-react";
import { useState, useMemo } from "react";
import { Database, Globe, ChevronDown, ChevronRight, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
interface SourceReference {
id?: number;
title: string;
filename?: string;
page?: number;
@@ -17,101 +20,260 @@ interface SourceReference {
interface SourceReferencesProps {
sources: SourceReference[];
maxSources?: number;
answerContent?: string;
messageId?: string;
}
export default function SourceReferences({ sources, maxSources = 5 }: SourceReferencesProps) {
if (!sources || sources.length === 0) {
return null;
/** 从 answer 中提取长度 >= minLen 的不重叠片段,用于在来源原文中高亮匹配 */
function findMatchedSpans(
answer: string,
sourceText: string,
minLen = 15
): Array<{ start: number; end: number }> {
if (!answer || !sourceText) return [];
// 从 answer 中切分出所有有意义的中文/英文片段
const segments: string[] = [];
// 按句号、换行等断开
const sentences = answer.split(/[。,;!?\n、:()]/);
for (const s of sentences) {
const trimmed = s.trim();
if (trimmed.length >= minLen) {
segments.push(trimmed);
}
}
const displaySources = sources.slice(0, maxSources);
const ragSources = displaySources.filter(s => s.source_type !== "web");
const webSources = displaySources.filter(s => s.source_type === "web");
// 在 sourceText 中查找每个片段
const spans: Array<{ start: number; end: number }> = [];
for (const seg of segments) {
let pos = 0;
while (pos < sourceText.length) {
const idx = sourceText.indexOf(seg, pos);
if (idx === -1) break;
const end = idx + seg.length;
// 检查是否与已有 span 重叠,有则合并
const overlapping = spans.find(
(s) => !(end <= s.start || idx >= s.end)
);
if (overlapping) {
overlapping.start = Math.min(overlapping.start, idx);
overlapping.end = Math.max(overlapping.end, end);
} else {
spans.push({ start: idx, end });
}
pos = end;
}
}
return spans.sort((a, b) => a.start - b.start);
}
/** 将匹配的 span 用 <mark> 包裹 */
function highlightText(
text: string,
spans: Array<{ start: number; end: number }>
): React.ReactNode {
if (!spans.length) return text;
// 合并重叠/相邻的 span
const merged: Array<{ start: number; end: number }> = [];
for (const span of spans) {
const last = merged[merged.length - 1];
if (last && span.start <= last.end + 3) {
last.end = Math.max(last.end, span.end);
} else {
merged.push({ ...span });
}
}
const parts: React.ReactNode[] = [];
let last = 0;
for (const span of merged) {
if (span.start > last) {
parts.push(text.slice(last, span.start));
}
parts.push(
<mark key={span.start} className="bg-yellow-200/70 dark:bg-yellow-500/30 rounded-sm px-0.5">
{text.slice(span.start, span.end)}
</mark>
);
last = span.end;
}
if (last < text.length) {
parts.push(text.slice(last));
}
return <>{parts}</>;
}
function SourceRow({
source,
answerContent,
messageId,
}: {
source: SourceReference;
answerContent?: string;
messageId?: string;
}) {
const [showPreview, setShowPreview] = useState(false);
const matchedSpans = useMemo(() => {
if (!answerContent || !source.preview) return [];
return findMatchedSpans(answerContent, source.preview);
}, [answerContent, source.preview]);
const previewContent = useMemo(() => {
if (!matchedSpans.length) return source.preview;
return highlightText(source.preview, matchedSpans);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPreview, matchedSpans]);
return (
<div className="mt-4 space-y-3">
{ragSources.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-blue-600">
<Database className="h-4 w-4" />
<span> ({ragSources.length})</span>
</div>
<div className="space-y-2">
{ragSources.map((source, index) => (
<Card key={index} className="border border-blue-200 bg-blue-50/30 hover:border-blue-300 transition-colors">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-sm font-medium line-clamp-2">
{source.title}
</CardTitle>
{source.score != null && source.score > 0 && source.score < 1 && (
<div className="flex items-center gap-1 ml-2">
<Star className="h-3 w-3 text-yellow-500" />
<span className="text-xs text-gray-500">
{(source.score * 100).toFixed(1)}%
</span>
</div>
)}
</div>
<div className="text-xs text-gray-500">
{source.filename}
{source.page && ` • 第 ${source.page}`}
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-gray-600 line-clamp-2">
{source.preview}
</p>
</CardContent>
</Card>
))}
</div>
</div>
)}
{webSources.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-green-600">
<Globe className="h-4 w-4" />
<span> ({webSources.length})</span>
</div>
<div className="space-y-2">
{webSources.map((source, index) => (
<Card key={index} className="border border-green-200 bg-green-50/30 hover:border-green-300 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium line-clamp-2">
{source.title}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-gray-600 line-clamp-2 mb-2">
{source.preview}
</p>
{source.url && (
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => window.open(source.url, '_blank')}
>
<ExternalLink className="h-3 w-3 mr-1" />
</Button>
)}
</CardContent>
</Card>
))}
</div>
</div>
)}
{sources.length > maxSources && (
<div className="text-xs text-gray-500 text-center">
{sources.length - maxSources}
<div
id={source.id != null && messageId ? `source-${messageId}-${source.id}` : undefined}
className="scroll-mt-20"
>
<button
className={cn(
"w-full flex items-center gap-1.5 px-2 py-1 -mx-2 rounded text-left",
"hover:bg-muted/60 transition-colors group text-xs"
)}
onClick={() => setShowPreview(!showPreview)}
>
<ChevronRight
className={cn(
"h-3 w-3 flex-shrink-0 text-muted-foreground/60 transition-transform",
showPreview && "rotate-90"
)}
/>
{source.id != null && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full bg-primary/10 text-primary text-[10px] font-bold flex-shrink-0">
{source.id}
</span>
)}
<span className="font-medium truncate">{source.title}</span>
{source.filename && (
<span className="text-muted-foreground/70 truncate hidden sm:inline">
{source.filename.replace(/\.(pdf|docx?|txt|md)$/i, "")}
</span>
)}
{source.score != null && source.score > 0 && source.score < 1 && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground flex-shrink-0 ml-auto">
<Star className="h-2.5 w-2.5 text-yellow-500" />
{(source.score * 100).toFixed(0)}%
</span>
)}
</button>
{showPreview && (
<div className="ml-7 pl-3 pr-2 py-1.5 mb-0.5 border-l-2 border-primary/20 bg-muted/30 rounded-r text-xs text-muted-foreground leading-relaxed max-h-48 overflow-y-auto">
{previewContent}
</div>
)}
</div>
);
}
export default function SourceReferences({
sources,
maxSources = 20,
answerContent,
messageId,
}: SourceReferencesProps) {
const [expanded, setExpanded] = useState(false);
if (!sources || sources.length === 0) {
return null;
}
const displaySources = sources.slice(0, maxSources);
const showExpandButton = sources.length > maxSources;
const visibleSources = expanded ? sources : displaySources;
const ragSources = visibleSources.filter((s) => s.source_type !== "web");
const webSources = visibleSources.filter((s) => s.source_type === "web");
const sourceSection = (
<div className="space-y-1">
{ragSources.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
<Database className="h-3 w-3 text-blue-500" />
<span>
({ragSources.length}
{expanded && showExpandButton
? `/${sources.filter((s) => s.source_type !== "web").length}`
: ""}
)
</span>
</div>
<div className="divide-y divide-border/30">
{ragSources.map((source, i) => (
<SourceRow
key={i}
source={source}
answerContent={answerContent}
messageId={messageId}
/>
))}
</div>
</div>
)}
{webSources.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
<Globe className="h-3 w-3 text-green-500" />
<span>
({webSources.length}
{expanded && showExpandButton
? `/${sources.filter((s) => s.source_type === "web").length}`
: ""}
)
</span>
</div>
<div className="divide-y divide-border/30">
{webSources.map((source, i) => (
<SourceRow
key={i}
source={source}
answerContent={answerContent}
messageId={messageId}
/>
))}
</div>
</div>
)}
</div>
);
return (
<div className="mt-3 space-y-2">
{expanded ? (
<ScrollArea className="max-h-96">
<div className="pr-3">{sourceSection}</div>
</ScrollArea>
) : (
sourceSection
)}
{showExpandButton && (
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded
? `收起(共 ${sources.length} 个)`
: `展开全部 ${sources.length} 个来源`}
<ChevronDown
className={cn(
"ml-1 h-3 w-3 transition-transform",
expanded && "rotate-180"
)}
/>
</Button>
)}
</div>
);
}
+11 -2
View File
@@ -7,7 +7,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ThemeToggle } from "@/components/ui/theme-toggle";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useAuthStore } from "@/store/auth";
import { BookOpen, LogOut, User as UserIcon, Settings, ChevronDown, MessageSquare, Database, Image, TrendingUp, GraduationCap, Users } from "lucide-react";
import { BookOpen, LogOut, User as UserIcon, Settings, ChevronDown, MessageSquare, Database, Image, TrendingUp, GraduationCap, Users, ShieldCheck } from "lucide-react";
import { User } from "@/types";
interface NavbarProps {
@@ -21,6 +21,7 @@ const NAV_LINKS = [
{ href: "/knowledge", label: "知识库", icon: Database },
{ href: "/spatial", label: "空间设计", icon: Image },
{ href: "/forum", label: "课程社区", icon: Users },
{ href: "/admin", label: "后台管理", icon: ShieldCheck, adminOnly: true },
];
export default function Navbar({ isAuthenticated, user }: NavbarProps) {
@@ -54,7 +55,7 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
{/* 导航链接 — 带底部下划线指示 */}
{isAuthenticated && (
<div className="hidden md:flex items-center h-full -mb-px">
{NAV_LINKS.map((item) => {
{NAV_LINKS.filter((item) => !item.adminOnly || user?.is_superuser).map((item) => {
const active = isActive(item.href);
return (
<Link
@@ -148,6 +149,14 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
</Link>
</DropdownMenuItem>
{user.is_superuser && (
<DropdownMenuItem asChild>
<Link href="/admin" className="flex items-center cursor-pointer">
<ShieldCheck className="w-4 h-4 mr-2" />
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="text-destructive cursor-pointer">
<LogOut className="w-4 h-4 mr-2" />
+12 -1
View File
@@ -15,7 +15,8 @@ import {
LogOut,
GraduationCap,
TrendingUp,
Users
Users,
ShieldCheck
} from "lucide-react";
const navItems = [
@@ -131,6 +132,16 @@ export default function MobileNav() {
{/* 底部操作 */}
<div className="p-6 border-t border-gray-200 space-y-2">
{user?.is_superuser && (
<Button
variant="ghost"
onClick={() => handleNavClick("/admin")}
className="w-full justify-start"
>
<ShieldCheck className="w-5 h-5 mr-3" />
</Button>
)}
<Button
variant="ghost"
onClick={() => handleNavClick("/analytics")}
+129
View File
@@ -583,6 +583,9 @@ export const chatAPI = {
} else if (data.type === "chunk") {
console.log("[DEBUG-STREAM] 接收chunk:", data.content);
onChunk?.(data.content);
} else if (data.type === "sources") {
console.log("[DEBUG-STREAM] 收到sources:", data.sources?.length, "个来源");
onChunk?.(JSON.stringify(data));
} else if (data.type === "done") {
console.log("[DEBUG-STREAM] 流式完成, session_id:", data.session_id, "message_id:", data.message_id, "user_message_id:", data.user_message_id);
onComplete?.(data.session_id, data.message_id, data.user_message_id);
@@ -1067,3 +1070,129 @@ export const bookAPI = {
return `${API_BASE_URL}/books/${bookId}/file`;
},
};
// ===== 后台管理 API =====
export const adminAPI = {
// 仪表盘
async getDashboard() {
return apiRequest<{
total_users: number;
active_users_7d: number;
total_sessions: number;
total_messages: number;
total_documents: number;
total_knowledge_bases: number;
total_forum_posts: number;
total_forum_replies: number;
total_generated_images: number;
}>("/admin/dashboard");
},
async getUserTrends(days = 30) {
return apiRequest<{ date: string; count: number }[]>(`/admin/trends/users?days=${days}`);
},
async getMessageTrends(days = 30) {
return apiRequest<{ date: string; count: number }[]>(`/admin/trends/messages?days=${days}`);
},
// 用户管理
async listUsers(skip = 0, limit = 50) {
return apiRequest<{
id: number;
username: string;
email: string;
full_name: string | null;
is_active: boolean;
is_superuser: boolean;
created_at: string | null;
last_login: string | null;
session_count: number;
message_count: number;
}[]>(`/admin/users?skip=${skip}&limit=${limit}`);
},
async toggleUserActive(userId: number) {
return apiRequest<{ success: boolean; is_active: boolean }>(`/admin/users/${userId}/toggle-active`, { method: "PUT" });
},
async toggleUserAdmin(userId: number) {
return apiRequest<{ success: boolean; is_superuser: boolean }>(`/admin/users/${userId}/toggle-admin`, { method: "PUT" });
},
async deleteUser(userId: number) {
return apiRequest<{ success: boolean }>(`/admin/users/${userId}`, { method: "DELETE" });
},
// 论坛管理
async listForumCategories() {
return apiRequest<{
id: number;
slug: string;
name: string;
description: string | null;
post_count: number;
}[]>("/admin/forum/categories");
},
async createForumCategory(data: { name: string; slug: string; description?: string }) {
return apiRequest("/admin/forum/categories", {
method: "POST",
body: JSON.stringify(data),
});
},
async updateForumCategory(categoryId: number, data: { name: string; slug: string; description?: string }) {
return apiRequest(`/admin/forum/categories/${categoryId}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
async deleteForumCategory(categoryId: number) {
return apiRequest<{ success: boolean }>(`/admin/forum/categories/${categoryId}`, { method: "DELETE" });
},
async listForumPosts(skip = 0, limit = 50) {
return apiRequest<{
id: number;
title: string;
author_name: string;
category_name: string;
reply_count: number;
created_at: string;
}[]>(`/admin/forum/posts?skip=${skip}&limit=${limit}`);
},
async deleteForumPost(postId: number) {
return apiRequest<{ success: boolean }>(`/admin/forum/posts/${postId}`, { method: "DELETE" });
},
// 知识库管理
async listKnowledgeBases() {
return apiRequest<{
id: number;
name: string;
description: string | null;
owner_name: string;
is_system: boolean;
document_count: number;
chunk_count: number;
created_at: string;
}[]>("/admin/knowledge-bases");
},
async deleteKnowledgeBase(kbId: number) {
return apiRequest<{ success: boolean }>(`/admin/knowledge-bases/${kbId}`, { method: "DELETE" });
},
// 系统状态
async getSystemStatus() {
return apiRequest<{
database: { status: string };
vector_store: { status: string; vector_count: number };
llm_model: string;
embedding_model: string;
}>("/admin/system/status");
},
};
+23
View File
@@ -309,6 +309,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
thinkingBuffer.push(step);
scheduleFlush();
return;
} else if (data.type === 'sources') {
// 更新助手消息的元数据(sources)
set((state) => ({
messages: state.messages.map(msg => {
if (msg.id !== assistantMessage.id) return msg;
return {
...msg,
metadata: { ...(msg.metadata || {}), sources: data.sources },
};
}),
}));
return;
} else if (data.type === 'chunk') {
chunkCount++;
totalChars += data.content.length;
@@ -500,6 +512,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
regenThinkingBuffer.push(step);
regenSchedule();
return;
} else if (data.type === 'sources') {
set((state) => ({
messages: state.messages.map(msg => {
if (msg.id !== assistantPlaceholder.id) return msg;
return {
...msg,
metadata: { ...(msg.metadata || {}), sources: data.sources },
};
}),
}));
return;
} else if (data.type === 'chunk') {
regenContentBuffer += data.content;
regenSchedule();
+1
View File
@@ -94,6 +94,7 @@ export interface ChatMessage {
}
export interface SourceInfo {
id?: number;
title: string;
filename?: string;
page?: number;