Files
course-agent-od/web/src/app/(main)/admin/course/page.tsx
T
pengxiao abcced35b8 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>
2026-05-27 20:51:28 +08:00

167 lines
6.4 KiB
TypeScript

"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>
);
}