@@ -1164,8 +1513,8 @@ const StepDataView = memo(function StepDataView({ data, toolName }: { data: Reco
/* writing_assist — 写作助手结果 */
if (toolName === "writing_assist" && data.content) {
return (
-
-
}>
+
+ }>
{String(data.content)}
@@ -1173,7 +1522,7 @@ const StepDataView = memo(function StepDataView({ data, toolName }: { data: Reco
}
/* 兜底:原始 JSON */
return (
-
+
{JSON.stringify(data, null, 2)}
);
@@ -1182,39 +1531,67 @@ const StepDataView = memo(function StepDataView({ data, toolName }: { data: Reco
/* ========== 确认卡片 ========== */
const ActionConfirmCard = memo(function ActionConfirmCard({
- actionId, description, tool, args, isPending, isConfirming, onConfirm, onReject,
+ actionId,
+ description,
+ tool,
+ args,
+ isPending,
+ isConfirming,
+ onConfirm,
+ onReject,
}: {
- actionId: string; description: string; tool: string; args?: Record;
- isPending: boolean; isConfirming: boolean; onConfirm: (id: string) => void; onReject: (id: string) => void;
+ actionId: string;
+ description: string;
+ tool: string;
+ args?: Record;
+ isPending: boolean;
+ isConfirming: boolean;
+ onConfirm: (id: string) => void;
+ onReject: (id: string) => void;
}) {
const meta = getToolMeta(tool);
const Icon = meta.icon;
return (
-
-
-
-
{isPending ? "⚠️ 需要你的确认" : "已处理"}
+
+
+
+
+ {isPending ? "⚠️ 需要你的确认" : "已处理"}
+
-
-
+
+
-
{description}
+
{description}
{args && Object.keys(args).length > 0 && (
-
+
{Object.entries(args).map(([k, v]) => (
- {k}:
- {typeof v === "string" ? v : JSON.stringify(v)}
+ {k}:
+
+ {typeof v === "string" ? v : JSON.stringify(v)}
+
))}
@@ -1223,18 +1600,30 @@ const ActionConfirmCard = memo(function ActionConfirmCard({
{isPending && (
-
)}
{!isPending && (
-
+
已处理
@@ -1245,16 +1634,22 @@ const ActionConfirmCard = memo(function ActionConfirmCard({
);
});
-const ErrorCard = memo(function ErrorCard({ content, onRetry }: { content: string; onRetry?: () => void }) {
+const ErrorCard = memo(function ErrorCard({
+ content,
+ onRetry,
+}: {
+ content: string;
+ onRetry?: () => void;
+}) {
return (
-
-
-
{content}
+
+
+
{content}
{onRetry && (
重试
@@ -1268,9 +1663,15 @@ const ErrorCard = memo(function ErrorCard({ content, onRetry }: { content: strin
/* ========== 嵌入式内容卡片(Artifact) ========== */
const ArtifactCard = memo(function ArtifactCard({
- title, content, isHtml, onOpen,
+ title,
+ content,
+ isHtml,
+ onOpen,
}: {
- title: string; content: string; isHtml?: boolean; onOpen: () => void;
+ title: string;
+ content: string;
+ isHtml?: boolean;
+ onOpen: () => void;
}) {
const navigate = useNavigate();
const [expanded, setExpanded] = useState(false);
@@ -1280,37 +1681,54 @@ const ArtifactCard = memo(function ArtifactCard({
const bgAccent = isWiki ? "bg-primary/5" : "bg-amber-50 dark:bg-amber-900/10";
const IconComp = isWiki ? FileText : Newspaper;
- const preview = (isHtml
- ? content.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ")
- : content.replace(/[#*_`\[\]()>-]/g, "").replace(/\s+/g, " ")
- ).trim().slice(0, 200);
+ const preview = (
+ isHtml
+ ? content.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ")
+ : content.replace(/[#*_`\[\]()>-]/g, "").replace(/\s+/g, " ")
+ )
+ .trim()
+ .slice(0, 200);
return (
-
+
-
+
-
{title}
-
{preview}...
+
{title}
+
{preview}...
-
+
setExpanded(!expanded)}
- className="flex items-center gap-1 text-[11px] text-ink-tertiary hover:text-ink-secondary"
+ className="text-ink-tertiary hover:text-ink-secondary flex items-center gap-1 text-[11px]"
>
{expanded ? : }
{expanded ? "收起预览" : "展开预览"}
@@ -1319,7 +1737,7 @@ const ArtifactCard = memo(function ArtifactCard({
{expanded && (
{
const card = (e.target as HTMLElement).closest
("[data-paper-id]");
if (card?.dataset.paperId) navigate(`/papers/${card.dataset.paperId}`);
@@ -1332,7 +1750,7 @@ const ArtifactCard = memo(function ArtifactCard({
/>
) : (
- }>
+ }>
{content}
diff --git a/frontend/src/pages/CSFeeds.tsx b/frontend/src/pages/CSFeeds.tsx
new file mode 100644
index 0000000..ca2dc69
--- /dev/null
+++ b/frontend/src/pages/CSFeeds.tsx
@@ -0,0 +1,261 @@
+import { useEffect, useState, useCallback } from "react";
+import { Loader2, RefreshCw, Layers, Pencil, Check, X, Play } from "lucide-react";
+import { topicApi } from "@/services/api";
+import { Button } from "@/components/ui";
+
+interface CSCategory {
+ code: string;
+ name: string;
+ description: string;
+}
+
+interface CSFeed {
+ category_code: string;
+ category_name: string;
+ daily_limit: number;
+ enabled: boolean;
+ status: string;
+ last_run_at: string | null;
+ last_run_count: number;
+}
+
+export default function CSFeeds() {
+ const [categories, setCategories] = useState([]);
+ const [feeds, setFeeds] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [globalLimit, setGlobalLimit] = useState(30);
+ const [editingCode, setEditingCode] = useState(null);
+ const [editLimit, setEditLimit] = useState(30);
+ const [fetchingCode, setFetchingCode] = useState(null);
+
+ const loadData = useCallback(async () => {
+ setLoading(true);
+ try {
+ const [catRes, feedRes] = await Promise.all([topicApi.csCategories(), topicApi.csFeeds()]);
+ setCategories(catRes.categories || []);
+ setFeeds(feedRes.feeds || []);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ const subscribedCodes = new Set(feeds.map((f) => f.category_code));
+
+ async function toggleCategory(code: string) {
+ if (subscribedCodes.has(code)) {
+ await topicApi.csFeedDelete(code);
+ } else {
+ await topicApi.csFeedCreate({ category_codes: [code], daily_limit: globalLimit });
+ }
+ await loadData();
+ }
+
+ async function handleFetch(code: string) {
+ setFetchingCode(code);
+ try {
+ await topicApi.csFeedFetch(code);
+ } finally {
+ setFetchingCode(null);
+ }
+ }
+
+ async function updateLimit(code: string, newLimit: number) {
+ await topicApi.csFeedUpdate(code, { daily_limit: newLimit });
+ setEditingCode(null);
+ await loadData();
+ }
+
+ function startEdit(feed: CSFeed) {
+ setEditingCode(feed.category_code);
+ setEditLimit(feed.daily_limit);
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
arXiv CS 分类订阅
+
订阅感兴趣的 CS 细分领域,自动抓取最新论文
+
+
+ 新增默认配额
+ setGlobalLimit(Number(e.target.value))}
+ className="border-border bg-page h-8 w-16 rounded-lg border px-2 text-center text-sm"
+ min={1}
+ max={200}
+ />
+ 篇/天
+
+
+
+
+
+
+ 共 {categories.length} 个分类 · 已订阅 {feeds.length} 个
+
+ }
+ onClick={loadData}
+ >
+ 刷新
+
+
+
+ {categories.map((c) => {
+ const subscribed = subscribedCodes.has(c.code);
+ const feed = feeds.find((f) => f.category_code === c.code);
+ return (
+
+ );
+ })}
+
+
+
+ {feeds.length > 0 && (
+
+
已订阅分类
+
+ {feeds.map((f) => (
+
+
+
+
+
+
+ {f.category_code}
+
+
+ {f.status === "active"
+ ? "运行中"
+ : f.status === "cool_down"
+ ? "冷却中"
+ : "已暂停"}
+
+
+
+ {f.last_run_at && `上次 ${new Date(f.last_run_at).toLocaleDateString()} · `}
+ 已入库 {f.last_run_count} 篇
+
+
+
+
+ {editingCode === f.category_code ? (
+ <>
+
setEditLimit(Number(e.target.value))}
+ className="border-border bg-page h-7 w-16 rounded-lg border px-2 text-center text-xs"
+ min={1}
+ max={200}
+ />
+
篇/天
+
updateLimit(f.category_code, editLimit)}
+ className="hover:bg-success/10 text-success rounded p-1"
+ >
+
+
+
setEditingCode(null)}
+ className="hover:bg-error/10 text-error rounded p-1"
+ >
+
+
+ >
+ ) : (
+ <>
+
handleFetch(f.category_code)}
+ disabled={fetchingCode === f.category_code}
+ className="bg-primary/8 text-primary hover:bg-primary/15 flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-medium disabled:opacity-50"
+ >
+ {fetchingCode === f.category_code ? (
+
+ ) : (
+
+ )}
+ {fetchingCode === f.category_code ? "抓取中" : "手动抓取"}
+
+
{f.daily_limit} 篇/天
+
startEdit(f)}
+ className="hover:bg-hover text-ink-tertiary rounded p-1"
+ >
+
+
+
toggleCategory(f.category_code)}
+ className="text-error hover:text-error/80 hover:bg-error/10 rounded px-2 py-1 text-xs"
+ >
+ 取消
+
+ >
+ )}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx
index 024481c..8b4cbc9 100644
--- a/frontend/src/pages/Chat.tsx
+++ b/frontend/src/pages/Chat.tsx
@@ -8,13 +8,7 @@ import { Card, Button } from "@/components/ui";
import { ragApi } from "@/services/api";
import type { ChatMessage } from "@/types";
import { uid } from "@/lib/utils";
-import {
- Send,
- Sparkles,
- User,
- BookOpen,
- Trash2,
-} from "lucide-react";
+import { Send, Sparkles, User, BookOpen, Trash2 } from "lucide-react";
export default function Chat() {
const [messages, setMessages] = useState([]);
@@ -84,10 +78,8 @@ export default function Chat() {
{/* 标题栏 */}
-
AI Chat
-
- 基于 RAG 的跨论文智能问答
-
+
AI Chat
+
基于 RAG 的跨论文智能问答
{messages.length > 0 && (
{messages.length === 0 ? (
-
-
+
+
-
- PaperMind AI
-
-
- 基于你收录的论文进行智能问答。
- 支持跨文档检索,自动引用来源论文。
+
PaperMind AI
+
+ 基于你收录的论文进行智能问答。 支持跨文档检索,自动引用来源论文。
{[
@@ -127,7 +116,7 @@ export default function Chat() {
setInput(q)}
- className="rounded-xl border border-border bg-page px-4 py-3 text-left text-sm text-ink-secondary transition-colors hover:border-primary/30 hover:bg-hover hover:text-ink"
+ className="border-border bg-page text-ink-secondary hover:border-primary/30 hover:bg-hover hover:text-ink rounded-xl border px-4 py-3 text-left text-sm transition-colors"
>
{q}
@@ -138,19 +127,17 @@ export default function Chat() {
messages.map((msg) => (
{msg.role === "assistant" && (
-
-
+
+
)}
@@ -158,8 +145,8 @@ export default function Chat() {
{msg.content}
) : (
<>
-
-
+
@@ -169,7 +156,7 @@ export default function Chat() {
{msg.cited_paper_ids.map((cid) => (
{cid.slice(0, 8)}...
@@ -180,14 +167,14 @@ export default function Chat() {
{/* Evidence */}
{msg.evidence && msg.evidence.length > 0 && (
-
+
查看 {msg.evidence.length} 条证据
{msg.evidence.map((ev, i) => (
{JSON.stringify(ev, null, 2)}
@@ -199,8 +186,8 @@ export default function Chat() {
)}
{msg.role === "user" && (
-
@@ -209,18 +196,18 @@ export default function Chat() {
{/* 加载中提示 */}
{loading && (
-
-
-
+
+
+
-
+
-
-
-
+
+
+
-
正在思考...
+
正在思考...
@@ -229,7 +216,7 @@ export default function Chat() {
{/* 输入区域 */}
-
+
-
+
基于 RAG 检索增强生成,回答可能不完全准确,请以原始论文为准
diff --git a/frontend/src/pages/Collect.tsx b/frontend/src/pages/Collect.tsx
index a551c8e..b8e4737 100644
--- a/frontend/src/pages/Collect.tsx
+++ b/frontend/src/pages/Collect.tsx
@@ -31,13 +31,24 @@ import {
Hash,
Zap,
Play,
+ Layers,
} from "lucide-react";
import { ingestApi, topicApi } from "@/services/api";
import { useToast } from "@/contexts/ToastContext";
import ConfirmDialog from "@/components/ConfirmDialog";
-import type { Topic, TopicCreate, TopicUpdate, ScheduleFrequency, KeywordSuggestion, IngestPaper, TopicFetchResult } from "@/types";
+import type {
+ Topic,
+ TopicCreate,
+ TopicUpdate,
+ ScheduleFrequency,
+ KeywordSuggestion,
+ IngestPaper,
+ TopicFetchResult,
+} from "@/types";
+import CSFeeds from "./CSFeeds";
type SortBy = "submittedDate" | "relevance" | "lastUpdatedDate";
+type ActiveTab = "search" | "subscriptions" | "csfeeds";
interface SearchResult {
ingested: number;
@@ -54,12 +65,24 @@ const FREQ_OPTIONS: { value: ScheduleFrequency; label: string; desc: string }[]
{ value: "weekdays", label: "工作日", desc: "周一至周五" },
{ value: "weekly", label: "每周", desc: "每周日" },
];
-const FREQ_LABEL: Record
= { daily: "每天", twice_daily: "每天两次", weekdays: "工作日", weekly: "每周" };
-
-function utcToBj(utc: number): number { return (utc + 8) % 24; }
-function bjToUtc(bj: number): number { return (bj - 8 + 24) % 24; }
+const FREQ_LABEL: Record = {
+ daily: "每天",
+ twice_daily: "每天两次",
+ weekdays: "工作日",
+ weekly: "每周",
+};
+
+function utcToBj(utc: number): number {
+ return (utc + 8) % 24;
+}
+function bjToUtc(bj: number): number {
+ return (bj - 8 + 24) % 24;
+}
function hourOptions(): { value: number; label: string }[] {
- return Array.from({ length: 24 }, (_, i) => ({ value: i, label: `${String(i).padStart(2, "0")}:00` }));
+ return Array.from({ length: 24 }, (_, i) => ({
+ value: i,
+ label: `${String(i).padStart(2, "0")}:00`,
+ }));
}
function relativeTime(iso: string): string {
@@ -79,6 +102,9 @@ export default function Collect() {
const { toast } = useToast();
const navigate = useNavigate();
+ // ========== Tab 切换 ==========
+ const [activeTab, setActiveTab] = useState("search");
+
// ========== 即时搜索 ==========
const [query, setQuery] = useState("");
const [maxResults, setMaxResults] = useState(20);
@@ -94,7 +120,9 @@ export default function Collect() {
const pollRef = useRef | null>(null);
useEffect(() => {
- return () => { if (pollRef.current) clearInterval(pollRef.current); };
+ return () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ };
}, []);
// ========== 表单 ==========
@@ -114,115 +142,172 @@ export default function Collect() {
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
useEffect(() => {
- topicApi.list(false).then((r) => { setTopics(r.items); setLoading(false); }).catch(() => setLoading(false));
+ topicApi
+ .list(false)
+ .then((r) => {
+ setTopics(r.items);
+ setLoading(false);
+ })
+ .catch(() => setLoading(false));
}, []);
// ========== 即时搜索 ==========
const handleSearch = useCallback(async () => {
if (!query.trim()) return;
- setSearching(true); setError("");
+ setSearching(true);
+ setError("");
try {
const res = await ingestApi.arxiv(query.trim(), maxResults, undefined, sortBy);
- setResults((prev) => [{
- ingested: res.ingested,
- papers: res.papers || [],
- query: query.trim(),
- sortBy,
- time: new Date().toLocaleTimeString("zh-CN"),
- expanded: true,
- }, ...prev.map(r => ({ ...r, expanded: false }))]);
+ setResults((prev) => [
+ {
+ ingested: res.ingested,
+ papers: res.papers || [],
+ query: query.trim(),
+ sortBy,
+ time: new Date().toLocaleTimeString("zh-CN"),
+ expanded: true,
+ },
+ ...prev.map((r) => ({ ...r, expanded: false })),
+ ]);
if (res.ingested > 0) toast("success", `成功收集 ${res.ingested} 篇论文`);
else toast("info", "未找到新论文(可能已全部收集)");
} catch (err) {
setError(err instanceof Error ? err.message : "搜索失败");
- } finally { setSearching(false); }
+ } finally {
+ setSearching(false);
+ }
}, [query, maxResults, sortBy, toast]);
// ========== 手动抓取订阅 ==========
- const handleManualFetch = useCallback(async (topicId: string) => {
- setFetchingTopicId(topicId);
- try {
- const res: TopicFetchResult = await topicApi.fetch(topicId);
- if (res.status === "started" || res.status === "already_running") {
- toast("info", res.topic_name || "抓取已在后台启动...");
- // 轮询状态
- if (pollRef.current) clearInterval(pollRef.current);
- pollRef.current = setInterval(async () => {
- try {
- const status = await topicApi.fetchStatus(topicId);
- if (status.status === "running") {
- // 显示进度
- toast("info", "抓取中...");
- return;
- }
- if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
- setFetchingTopicId(null);
- if (status.status === "ok" || status.status === "no_new_papers") {
- const newCount = status.inserted;
- const processed = status.processed ?? 0;
- let msg = `抓取完成:${newCount} 篇新论文`;
- if (processed > 0) msg += `,${processed} 篇处理`;
- toast("success", msg);
- // 显示进度
- return;
- }
- if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
- setFetchingTopicId(null);
- if (status.status === "ok" || status.status === "no_new_papers") {
- // 刷新整个订阅列表,确保 last_run_at 和 paper_count 更新
+ const handleManualFetch = useCallback(
+ async (topicId: string) => {
+ setFetchingTopicId(topicId);
+ try {
+ const res: TopicFetchResult = await topicApi.fetch(topicId);
+ if (res.status === "started" || res.status === "already_running") {
+ toast("info", res.topic_name || "抓取已在后台启动...");
+ // 轮询状态
+ if (pollRef.current) clearInterval(pollRef.current);
+ pollRef.current = setInterval(async () => {
+ try {
+ const status = await topicApi.fetchStatus(topicId);
+ if (status.status === "running") {
+ // 显示进度
+ toast("info", "抓取中...");
+ return;
+ }
+ if (pollRef.current) {
+ clearInterval(pollRef.current);
+ pollRef.current = null;
+ }
+ setFetchingTopicId(null);
+ if (status.status === "ok" || status.status === "no_new_papers") {
+ const newCount = status.inserted;
+ const processed = status.processed ?? 0;
+ let msg = `抓取完成:${newCount} 篇新论文`;
+ if (processed > 0) msg += `,${processed} 篇处理`;
+ toast("success", msg);
+ // 显示进度
+ return;
+ }
+ if (pollRef.current) {
+ clearInterval(pollRef.current);
+ pollRef.current = null;
+ }
+ setFetchingTopicId(null);
+ if (status.status === "ok" || status.status === "no_new_papers") {
+ // 刷新整个订阅列表,确保 last_run_at 和 paper_count 更新
+ const list = await topicApi.list(false);
+ setTopics(list.items);
+ return;
+ }
+ if (status.status === "failed") {
+ toast("error", `抓取失败:${status.error || "未知错误"}`);
+ }
+ // 无论如何都刷新列表
const list = await topicApi.list(false);
setTopics(list.items);
- return;
+ } catch {
+ if (pollRef.current) {
+ clearInterval(pollRef.current);
+ pollRef.current = null;
+ }
+ setFetchingTopicId(null);
}
- if (status.status === "failed") {
- toast("error", `抓取失败:${status.error || "未知错误"}`);
- }
- // 无论如何都刷新列表
- const list = await topicApi.list(false);
- setTopics(list.items);
- } catch {
- if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
- setFetchingTopicId(null);
- }
- }, 3000);
- return;
- }
- if (res.status === "ok") {
- const newCount = res.inserted;
- const processed = res.processed ?? 0;
- let msg = `抓取完成:${newCount} 篇新论文`;
- if (processed > 0) msg += `,${processed} 篇处理`;
- toast("success", msg);
- const list = await topicApi.list(false);
- setTopics(list.items);
- } else if (res.status === "no_new_papers") {
- toast("info", `⚠️ 没有新论文,已跳过处理`);
- } else {
- toast("error", `抓取失败:${res.error || "未知错误"}`);
+ }, 3000);
+ return;
+ }
+ if (res.status === "ok") {
+ const newCount = res.inserted;
+ const processed = res.processed ?? 0;
+ let msg = `抓取完成:${newCount} 篇新论文`;
+ if (processed > 0) msg += `,${processed} 篇处理`;
+ toast("success", msg);
+ const list = await topicApi.list(false);
+ setTopics(list.items);
+ } else if (res.status === "no_new_papers") {
+ toast("info", `⚠️ 没有新论文,已跳过处理`);
+ } else {
+ toast("error", `抓取失败:${res.error || "未知错误"}`);
+ }
+ } catch (err) {
+ toast("error", err instanceof Error ? err.message : "抓取失败");
+ } finally {
+ setFetchingTopicId(null);
}
- } catch (err) {
- toast("error", err instanceof Error ? err.message : "抓取失败");
- } finally { setFetchingTopicId(null); }
- }, [toast]);
+ },
+ [toast]
+ );
// ========== AI 建议 ==========
const handleAiSuggest = useCallback(async () => {
const desc = aiDesc.trim() || formQuery.trim() || query.trim();
if (!desc) return;
- setAiLoading(true); setSuggestions([]);
- try { const res = await topicApi.suggestKeywords(desc); setSuggestions(res.suggestions); }
- catch { setError("AI 建议失败"); } finally { setAiLoading(false); }
+ setAiLoading(true);
+ setSuggestions([]);
+ try {
+ const res = await topicApi.suggestKeywords(desc);
+ setSuggestions(res.suggestions);
+ } catch {
+ setError("AI 建议失败");
+ } finally {
+ setAiLoading(false);
+ }
}, [aiDesc, formQuery, query]);
- const applySuggestion = useCallback((s: KeywordSuggestion) => { setFormName(s.name); setFormQuery(s.query); setSuggestions([]); setAiDesc(""); }, []);
+ const applySuggestion = useCallback((s: KeywordSuggestion) => {
+ setFormName(s.name);
+ setFormQuery(s.query);
+ setSuggestions([]);
+ setAiDesc("");
+ }, []);
// ========== 表单操作 ==========
- const resetForm = useCallback(() => { setShowForm(false); setEditId(null); setFormName(""); setFormQuery(""); setFormMax(20); setFormFreq("daily"); setFormTimeBj(5); setSuggestions([]); setAiDesc(""); }, []);
- const openAdd = useCallback(() => { resetForm(); setShowForm(true); }, [resetForm]);
+ const resetForm = useCallback(() => {
+ setShowForm(false);
+ setEditId(null);
+ setFormName("");
+ setFormQuery("");
+ setFormMax(20);
+ setFormFreq("daily");
+ setFormTimeBj(5);
+ setSuggestions([]);
+ setAiDesc("");
+ }, []);
+ const openAdd = useCallback(() => {
+ resetForm();
+ setShowForm(true);
+ }, [resetForm]);
const openEdit = useCallback((t: Topic) => {
- setEditId(t.id); setFormName(t.name); setFormQuery(t.query); setFormMax(t.max_results_per_run);
- setFormFreq(t.schedule_frequency || "daily"); setFormTimeBj(utcToBj(t.schedule_time_utc ?? 21));
- setSuggestions([]); setAiDesc(""); setShowForm(true);
+ setEditId(t.id);
+ setFormName(t.name);
+ setFormQuery(t.query);
+ setFormMax(t.max_results_per_run);
+ setFormFreq(t.schedule_frequency || "daily");
+ setFormTimeBj(utcToBj(t.schedule_time_utc ?? 21));
+ setSuggestions([]);
+ setAiDesc("");
+ setShowForm(true);
}, []);
const handleSave = useCallback(async () => {
@@ -231,242 +316,452 @@ export default function Collect() {
try {
const utcHour = bjToUtc(formTimeBj);
if (editId) {
- const updated = await topicApi.update(editId, { query: formQuery.trim(), max_results_per_run: formMax, schedule_frequency: formFreq, schedule_time_utc: utcHour });
+ const updated = await topicApi.update(editId, {
+ query: formQuery.trim(),
+ max_results_per_run: formMax,
+ schedule_frequency: formFreq,
+ schedule_time_utc: utcHour,
+ });
setTopics((prev) => prev.map((x) => (x.id === editId ? updated : x)));
} else {
- const topic = await topicApi.create({ name: formName.trim(), query: formQuery.trim(), enabled: true, max_results_per_run: formMax, schedule_frequency: formFreq, schedule_time_utc: utcHour });
+ const topic = await topicApi.create({
+ name: formName.trim(),
+ query: formQuery.trim(),
+ enabled: true,
+ max_results_per_run: formMax,
+ schedule_frequency: formFreq,
+ schedule_time_utc: utcHour,
+ });
setTopics((prev) => [topic, ...prev]);
}
resetForm();
- } catch (err) { setError(err instanceof Error ? err.message : "保存失败"); } finally { setSaving(false); }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "保存失败");
+ } finally {
+ setSaving(false);
+ }
}, [formName, formQuery, formMax, formFreq, formTimeBj, editId, resetForm]);
- const handleToggle = useCallback(async (t: Topic) => {
- try {
- const updated = await topicApi.update(t.id, { enabled: !t.enabled });
- setTopics((prev) => prev.map((x) => (x.id === t.id ? updated : x)));
- } catch { toast("error", "切换订阅状态失败"); }
- }, [toast]);
+ const handleToggle = useCallback(
+ async (t: Topic) => {
+ try {
+ const updated = await topicApi.update(t.id, { enabled: !t.enabled });
+ setTopics((prev) => prev.map((x) => (x.id === t.id ? updated : x)));
+ } catch {
+ toast("error", "切换订阅状态失败");
+ }
+ },
+ [toast]
+ );
const handleDelete = useCallback(async (id: string) => {
- try { await topicApi.delete(id); setTopics((prev) => prev.filter((t) => t.id !== id)); } catch { toast("error", "删除订阅失败"); }
+ try {
+ await topicApi.delete(id);
+ setTopics((prev) => prev.filter((t) => t.id !== id));
+ } catch {
+ toast("error", "删除订阅失败");
+ }
}, []);
return (
{/* 页面头 */}
-
-
-
-
论文收集
-
搜索下载论文 · 创建订阅自动收集 · 随时手动触发抓取
+
+
+
+
+
+
+
论文收集
+
搜索下载论文 · 创建订阅自动收集
+
+ {/* Tab 切换 */}
+
+ setActiveTab("search")}
+ className={`flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all ${
+ activeTab === "search"
+ ? "bg-primary text-white shadow-sm"
+ : "text-ink-secondary hover:text-ink hover:bg-muted"
+ }`}
+ >
+
+ 即时搜索
+
+ setActiveTab("subscriptions")}
+ className={`flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all ${
+ activeTab === "subscriptions"
+ ? "bg-primary text-white shadow-sm"
+ : "text-ink-secondary hover:text-ink hover:bg-muted"
+ }`}
+ >
+
+ 主题订阅
+
+ setActiveTab("csfeeds")}
+ className={`flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all ${
+ activeTab === "csfeeds"
+ ? "bg-primary text-white shadow-sm"
+ : "text-ink-secondary hover:text-ink hover:bg-muted"
+ }`}
+ >
+
+ 分类订阅
+
+
+
{/* 错误 */}
{error && (
-
-
-
{error}
-
setError("")} className="text-error/60 hover:text-error">
+
+
+
{error}
+
setError("")}
+ className="text-error/60 hover:text-error"
+ >
+
+
)}
{/* ================================================================
* 即时搜索区
* ================================================================ */}
-
-
-
-
-
即时搜索
-
输入关键词从 arXiv 搜索,论文直接下载到本地库
+ {activeTab === "search" && (
+
+
+
+
+
+
+
即时搜索
+
+ 输入关键词从 arXiv 搜索,论文直接下载到本地库
+
+
-
- {/* 搜索栏 */}
-
-
-
-
setQuery(e.target.value)}
- onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
- placeholder="3D reconstruction, NeRF, LLM alignment..."
- className="h-11 w-full rounded-xl border border-border bg-page pl-10 pr-4 text-sm text-ink placeholder:text-ink-placeholder focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
- />
+ {/* 搜索栏 */}
+
+
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSearch();
+ }}
+ placeholder="3D reconstruction, NeRF, LLM alignment..."
+ className="border-border bg-page text-ink placeholder:text-ink-placeholder focus:border-primary focus:ring-primary/20 h-11 w-full rounded-xl border pr-4 pl-10 text-sm focus:ring-2 focus:outline-none"
+ />
+
+
+ ) : (
+
+ )
+ }
+ onClick={handleSearch}
+ loading={searching}
+ disabled={!query.trim()}
+ >
+ 搜索下载
+
-
:
} onClick={handleSearch} loading={searching} disabled={!query.trim()}>
- 搜索下载
-
-
- {/* 筛选条件 */}
-
-
-
- {query.trim() && (
-
{ setFormName(query.trim()); setFormQuery(query.trim()); setFormMax(maxResults); setShowForm(true); }}
- className="flex items-center gap-1.5 rounded-lg bg-primary/8 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/15"
- >
- 存为自动订阅
-
+ {/* 筛选条件 */}
+
+
+
+ {query.trim() && (
+
{
+ setFormName(query.trim());
+ setFormQuery(query.trim());
+ setFormMax(maxResults);
+ setShowForm(true);
+ setActiveTab("subscriptions");
+ }}
+ className="bg-primary/8 text-primary hover:bg-primary/15 flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
+ >
+ 存为订阅
+
+ )}
+
+
+ {/* 搜索结果 */}
+ {results.length > 0 && (
+
+ {results.map((r, i) => (
+
+ setResults((prev) =>
+ prev.map((x, j) => (j === i ? { ...x, expanded: !x.expanded } : x))
+ )
+ }
+ onNavigate={(paperId) => navigate(`/papers/${paperId}`)}
+ />
+ ))}
+
)}
-
- {/* 搜索结果 */}
- {results.length > 0 && (
-
- {results.map((r, i) => (
- setResults(prev => prev.map((x, j) => j === i ? { ...x, expanded: !x.expanded } : x))} onNavigate={(paperId) => navigate(`/papers/${paperId}`)} />
- ))}
-
- )}
-
+ )}
{/* ================================================================
* 自动订阅管理
* ================================================================ */}
-
-
-
-
-
-
自动订阅
-
定时自动收集,也可随时手动触发
+ {activeTab === "subscriptions" && (
+
+
+
+
+
+
+
+
自动订阅
+
定时自动收集,也可随时手动触发
+
+
} onClick={openAdd}>
+ 新建订阅
+
-
} onClick={openAdd}>新建订阅
-
- {/* 新建/编辑表单 */}
- {showForm && (
-
-
-
- {editId ? : }
- {editId ? "编辑订阅" : "新建订阅"}
-
-
-
-
-
-
-
- setFormName(e.target.value)} placeholder="例:3D 重建" disabled={!!editId}
- className="form-input" />
-
-
- setFormQuery(e.target.value)} placeholder="all:NeRF AND all:3D"
- className="form-input" />
-
-
-
-
+ {/* 新建/编辑表单 */}
+ {showForm && (
+
+
+
+ {editId ? (
+
+ ) : (
+
+ )}
+ {editId ? "编辑订阅" : "新建订阅"}
+
+
+
+
-
-
-
- {FREQ_OPTIONS.map((o) => (
- setFormFreq(o.value)}
- className={`rounded-lg border px-3 py-2 text-left text-xs transition-all ${formFreq === o.value ? "border-primary bg-primary/8 text-primary" : "border-border bg-surface text-ink-secondary hover:border-border/80"}`}>
- {o.label}
- {o.desc}
-
- ))}
-
-
-
-
-
-
+
+
+
+ setFormName(e.target.value)}
+ placeholder="例:3D 重建"
+ disabled={!!editId}
+ className="form-input"
+ />
+
+
+ setFormQuery(e.target.value)}
+ placeholder="all:NeRF AND all:3D"
+ className="form-input"
+ />
+
+
+
+
+
+
+
+
+
+ {FREQ_OPTIONS.map((o) => (
+ setFormFreq(o.value)}
+ className={`rounded-lg border px-3 py-2 text-left text-xs transition-all ${formFreq === o.value ? "border-primary bg-primary/8 text-primary" : "border-border bg-surface text-ink-secondary hover:border-border/80"}`}
+ >
+ {o.label}
+ {o.desc}
+
+ ))}
+
+
+
+
+
+
- {/* AI 关键词建议 */}
-
-
-
-
-
setAiDesc(e.target.value)}
- onKeyDown={(e) => { if (e.key === "Enter") handleAiSuggest(); }}
- placeholder="用自然语言描述你的研究兴趣,AI 自动生成搜索词..."
- className="h-9 w-full rounded-lg border border-border bg-surface px-3 text-xs text-ink placeholder:text-ink-placeholder focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" />
+ {/* AI 关键词建议 */}
+
+
+
+
+ setAiDesc(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleAiSuggest();
+ }}
+ placeholder="用自然语言描述你的研究兴趣,AI 自动生成搜索词..."
+ className="border-border bg-surface text-ink placeholder:text-ink-placeholder focus:border-primary focus:ring-primary/20 h-9 w-full rounded-lg border px-3 text-xs focus:ring-1 focus:outline-none"
+ />
+
+
+ ) : (
+
+ )
+ }
+ onClick={handleAiSuggest}
+ disabled={aiLoading || (!aiDesc.trim() && !formQuery.trim() && !query.trim())}
+ >
+ 生成
+
-
:
}
- onClick={handleAiSuggest} disabled={aiLoading || (!aiDesc.trim() && !formQuery.trim() && !query.trim())}>
- 生成
+ {suggestions.length > 0 && (
+
+ {suggestions.map((s, i) => (
+
applySuggestion(s)}
+ className="border-border bg-surface hover:border-primary/30 flex items-start gap-2 rounded-xl border p-3 text-left transition-all hover:shadow-sm"
+ >
+
+
+
{s.name}
+
+ {s.query}
+
+
{s.reason}
+
+
+ ))}
+
+ )}
+
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={handleSave}
+ loading={saving}
+ disabled={!formName.trim() || !formQuery.trim()}
+ >
+ {editId ? "保存修改" : "创建订阅"}
+
+
+ 取消
- {suggestions.length > 0 && (
-
- {suggestions.map((s, i) => (
-
applySuggestion(s)}
- className="flex items-start gap-2 rounded-xl border border-border bg-surface p-3 text-left transition-all hover:border-primary/30 hover:shadow-sm">
-
-
-
{s.name}
-
{s.query}
-
{s.reason}
-
-
- ))}
-
- )}
+
+ )}
-
-
:
}
- onClick={handleSave} loading={saving} disabled={!formName.trim() || !formQuery.trim()}>
- {editId ? "保存修改" : "创建订阅"}
+ {/* 订阅列表 */}
+ {loading ? (
+
+ ) : topics.length === 0 ? (
+
}
+ title="暂无订阅"
+ description="创建订阅后系统会按设定的频率自动收集论文"
+ action={
+
+ 创建第一个订阅
-
取消
-
+ }
+ />
+ ) : (
+
+ {topics.map((t) => (
+ openEdit(t)}
+ onToggle={() => handleToggle(t)}
+ onDelete={() => setConfirmDeleteId(t.id)}
+ onFetch={() => handleManualFetch(t.id)}
+ onNavigate={() => navigate(`/papers?topicId=${t.id}`)}
+ />
+ ))}
-
- )}
-
- {/* 订阅列表 */}
- {loading ? (
-
- ) : topics.length === 0 ? (
-
} title="暂无订阅" description="创建订阅后系统会按设定的频率自动收集论文" action={
创建第一个订阅} />
- ) : (
-
- {topics.map((t) => (
- openEdit(t)}
- onToggle={() => handleToggle(t)}
- onDelete={() => setConfirmDeleteId(t.id)}
- onFetch={() => handleManualFetch(t.id)}
- onNavigate={() => navigate(`/papers?topicId=${t.id}`)}
- />
- ))}
-
- )}
-
+ )}
+
+ )}
+
+ {/* ================================================================
+ * 分类订阅
+ * ================================================================ */}
+ {activeTab === "csfeeds" &&
}
{ if (confirmDeleteId) { await handleDelete(confirmDeleteId); setConfirmDeleteId(null); } }}
+ onConfirm={async () => {
+ if (confirmDeleteId) {
+ await handleDelete(confirmDeleteId);
+ setConfirmDeleteId(null);
+ }
+ }}
onCancel={() => setConfirmDeleteId(null)}
/>
);
}
-
/* ================================================================
* 订阅卡片
* ================================================================ */
@@ -506,27 +805,33 @@ function TopicCard({
const freqLabel = FREQ_LABEL[t.schedule_frequency] || "每天";
return (
-
+
{/* 状态指示灯 */}
{/* 主体信息 */}
-
{t.name}
-
+ {t.name}
+
{t.enabled ? "运行中" : "已暂停"}
{/* 搜索词 */}
-
{t.query}
+
{t.query}
{/* 统计信息 */}
-
+
{freqLabel} {String(bjHour).padStart(2, "0")}:00
@@ -536,7 +841,10 @@ function TopicCard({
每次 {t.max_results_per_run} 篇
{(t.paper_count ?? 0) > 0 && (
-
+
已收集 {t.paper_count} 篇
@@ -557,24 +865,41 @@ function TopicCard({
- {fetching ? : }
+ {fetching ? (
+
+ ) : (
+
+ )}
{fetching ? "抓取中..." : "手动抓取"}
-
+
-
+
-
+ title={t.enabled ? "暂停自动抓取" : "启用自动抓取"}
+ >
{t.enabled ? : }
-
+
@@ -583,52 +908,70 @@ function TopicCard({
);
}
-
/* ================================================================
* 即时搜索结果卡片
* ================================================================ */
-function SearchResultCard({ result: r, onToggle, onNavigate }: { result: SearchResult; onToggle: () => void; onNavigate: (id: string) => void }) {
+function SearchResultCard({
+ result: r,
+ onToggle,
+ onNavigate,
+}: {
+ result: SearchResult;
+ onToggle: () => void;
+ onNavigate: (id: string) => void;
+}) {
return (
-
+
{/* 头部:摘要信息 */}
-
+
-
- "{r.query}"
-
-
+ "{r.query}"
+
{r.ingested} 篇
{r.papers.length > 0 && !r.expanded && (
-
- {r.papers.slice(0, 3).map(p => p.title).join(" · ")}
+
+ {r.papers
+ .slice(0, 3)
+ .map((p) => p.title)
+ .join(" · ")}
)}
- {r.time}
- {r.papers.length > 0 && (
- r.expanded ? :
- )}
+ {r.time}
+ {r.papers.length > 0 &&
+ (r.expanded ? (
+
+ ) : (
+
+ ))}
{/* 展开:论文列表 */}
{r.expanded && r.papers.length > 0 && (
-
+
{r.papers.map((p) => (
-
-
+
+
-
{p.title}
-
+
{p.title}
+
{p.arxiv_id && {p.arxiv_id}}
{p.publication_date && {p.publication_date}}
-
onNavigate(p.id)} className="shrink-0 rounded-md p-1 text-ink-tertiary transition-colors hover:bg-primary/10 hover:text-primary" title="查看论文">
+ onNavigate(p.id)}
+ className="text-ink-tertiary hover:bg-primary/10 hover:text-primary shrink-0 rounded-md p-1 transition-colors"
+ title="查看论文"
+ >
@@ -640,15 +983,22 @@ function SearchResultCard({ result: r, onToggle, onNavigate }: { result: SearchR
);
}
-
/* ================================================================
* 通用表单字段
* ================================================================ */
-function FormField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
+function FormField({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
return (
-
- {hint &&
{hint}
}
+
+ {hint &&
{hint}
}
{children}
);
diff --git a/frontend/src/pages/DailyBrief.tsx b/frontend/src/pages/DailyBrief.tsx
index 4104859..cb0b64d 100644
--- a/frontend/src/pages/DailyBrief.tsx
+++ b/frontend/src/pages/DailyBrief.tsx
@@ -11,8 +11,19 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import { briefApi, generatedApi, tasksApi } from "@/services/api";
import type { GeneratedContentListItem, GeneratedContent } from "@/types";
import {
- Newspaper, Send, CheckCircle2, Mail, FileText, Calendar, Clock,
- Trash2, ChevronRight, Sparkles, Plus, RefreshCw, X,
+ Newspaper,
+ Send,
+ CheckCircle2,
+ Mail,
+ FileText,
+ Calendar,
+ Clock,
+ Trash2,
+ ChevronRight,
+ Sparkles,
+ Plus,
+ RefreshCw,
+ X,
} from "lucide-react";
export default function DailyBrief() {
@@ -35,18 +46,26 @@ export default function DailyBrief() {
const loadHistory = useCallback(async () => {
setHistoryLoading(true);
- try { const res = await generatedApi.list("daily_brief", 50); setHistory(res.items); }
- catch { toast("error", "加载历史简报失败"); } finally { setHistoryLoading(false); }
+ try {
+ const res = await generatedApi.list("daily_brief", 50);
+ setHistory(res.items);
+ } catch {
+ toast("error", "加载历史简报失败");
+ } finally {
+ setHistoryLoading(false);
+ }
}, [toast]);
- useEffect(() => { loadHistory(); }, [loadHistory]);
+ useEffect(() => {
+ loadHistory();
+ }, [loadHistory]);
// 自动加载最新一份
useEffect(() => {
if (history.length > 0 && !selectedContent) {
handleView(history[0]);
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [history]);
// 事件委托:点击简报中的论文卡片跳转到详情页
@@ -66,7 +85,10 @@ export default function DailyBrief() {
}, [navigate, selectedContent]);
const handleGenerate = async () => {
- setLoading(true); setError(null); setGenDone(false); setTaskProgress("正在提交任务...");
+ setLoading(true);
+ setError(null);
+ setGenDone(false);
+ setTaskProgress("正在提交任务...");
try {
const data: Record
= {};
if (date) data.date = date;
@@ -90,8 +112,14 @@ export default function DailyBrief() {
const status = await tasksApi.getStatus(taskId);
const pct = Math.round(status.progress * 100);
setTaskProgress(status.message || `生成中... ${pct}%`);
- if (status.status === "completed") { resolve(); return; }
- if (status.status === "failed") { reject(new Error(status.error || "生成失败")); return; }
+ if (status.status === "completed") {
+ resolve();
+ return;
+ }
+ if (status.status === "failed") {
+ reject(new Error(status.error || "生成失败"));
+ return;
+ }
} catch {
// 轮询出错不中断,继续重试
}
@@ -114,15 +142,26 @@ export default function DailyBrief() {
};
const handleView = async (item: GeneratedContentListItem) => {
- setDetailLoading(true); setSelectedContent(null);
- try { setSelectedContent(await generatedApi.detail(item.id)); }
- catch { toast("error", "加载简报内容失败"); } finally { setDetailLoading(false); }
+ setDetailLoading(true);
+ setSelectedContent(null);
+ try {
+ setSelectedContent(await generatedApi.detail(item.id));
+ } catch {
+ toast("error", "加载简报内容失败");
+ } finally {
+ setDetailLoading(false);
+ }
};
const handleDelete = async (id: string, e?: React.MouseEvent) => {
e?.stopPropagation();
- try { await generatedApi.delete(id); setHistory((p) => p.filter((h) => h.id !== id)); if (selectedContent?.id === id) setSelectedContent(null); }
- catch { toast("error", "删除简报失败"); }
+ try {
+ await generatedApi.delete(id);
+ setHistory((p) => p.filter((h) => h.id !== id));
+ if (selectedContent?.id === id) setSelectedContent(null);
+ } catch {
+ toast("error", "删除简报失败");
+ }
};
const fmtDate = (iso: string) => {
@@ -130,22 +169,31 @@ export default function DailyBrief() {
if (isNaN(d.getTime())) return iso;
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
- const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
+ const yesterday = new Date(now);
+ yesterday.setDate(now.getDate() - 1);
const isYesterday = d.toDateString() === yesterday.toDateString();
- if (isToday) return `今天 ${d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`;
- if (isYesterday) return `昨天 ${d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`;
- return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" }) + " " + d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
+ if (isToday)
+ return `今天 ${d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`;
+ if (isYesterday)
+ return `昨天 ${d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`;
+ return (
+ d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" }) +
+ " " +
+ d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
+ );
};
return (
{/* 顶栏 */}
-
+
-
+
+
+
-
研究简报
-
自动汇总最新研究进展
+
研究简报
+
自动汇总最新研究进展
+
- {error &&
{error}
}
+ {error &&
{error}
}
{taskProgress && !error && (
-
+
{taskProgress}
)}
{genDone && !loading && (
-
- 生成成功
+
+ 生成成功
)}
@@ -199,17 +265,19 @@ export default function DailyBrief() {
{/* 主体:左侧列表 + 右侧内容 */}
{/* 左侧历史列表 */}
-
-
-
+
+
+
历史简报 ({history.length})
{historyLoading ? (
-
+
+
+
) : history.length === 0 ? (
-
-
+
+
暂无简报
) : (
@@ -222,22 +290,31 @@ export default function DailyBrief() {
tabIndex={0}
key={item.id}
onClick={() => handleView(item)}
- onKeyDown={(e) => { if (e.key === "Enter") handleView(item); }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleView(item);
+ }}
className={`group flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-all ${
- active
- ? "bg-primary/10 text-primary"
- : "text-ink hover:bg-surface"
+ active ? "bg-primary/10 text-primary" : "text-ink hover:bg-surface"
}`}
>
-
+
-
{item.title.replace("Daily Brief: ", "")}
-
{fmtDate(item.created_at)}
+
+ {item.title.replace("Daily Brief: ", "")}
+
+
+ {fmtDate(item.created_at)}
+
{ e.stopPropagation(); setConfirmDeleteId(item.id); }}
- className="shrink-0 rounded p-0.5 text-ink-tertiary opacity-0 transition-opacity hover:text-error group-hover:opacity-100"
+ onClick={(e) => {
+ e.stopPropagation();
+ setConfirmDeleteId(item.id);
+ }}
+ className="text-ink-tertiary hover:text-error shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
>
@@ -259,11 +336,13 @@ export default function DailyBrief() {
{!detailLoading && selectedContent && (
{/* 内容头 */}
-
-
{selectedContent.title}
-
+
+
{selectedContent.title}
+
- {new Date(selectedContent.created_at).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}
+ {new Date(selectedContent.created_at).toLocaleString("zh-CN", {
+ timeZone: "Asia/Shanghai",
+ })}
@@ -272,15 +351,21 @@ export default function DailyBrief() {
)}
{!detailLoading && !selectedContent && (
-
-
+
)}
@@ -296,7 +381,12 @@ export default function DailyBrief() {
description="确定要删除这份研究简报吗?"
variant="danger"
confirmLabel="删除"
- onConfirm={async () => { if (confirmDeleteId) { await handleDelete(confirmDeleteId); setConfirmDeleteId(null); } }}
+ onConfirm={async () => {
+ if (confirmDeleteId) {
+ await handleDelete(confirmDeleteId);
+ setConfirmDeleteId(null);
+ }
+ }}
onCancel={() => setConfirmDeleteId(null)}
/>
@@ -304,11 +394,11 @@ export default function DailyBrief() {
}
/**
- * 覆盖后端生成的 HTML 简报样式,适配 app 主题 + 暗色模式
+ * 覆盖后端生成的 HTML 简报样式,适配 app 主题 + 暗色模式 + 增强视觉层次
*/
const briefContentStyles = `
.brief-content {
- max-width: 720px;
+ max-width: 800px;
margin: 0 auto;
color: var(--color-ink, #1a1a2e);
font-family: inherit;
@@ -326,319 +416,504 @@ const briefContentStyles = `
box-sizing: border-box;
}
-/* 标题 */
+/* 标题增强 */
.brief-content h1 {
- font-size: 1.5rem;
- font-weight: 800;
- margin-bottom: 4px;
- color: var(--color-ink, #111);
+ font-size: 1.75rem;
+ font-weight: 900;
+ margin-bottom: 8px;
+ background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
}
.brief-content .subtitle {
- font-size: 0.8rem;
+ font-size: 0.85rem;
color: var(--color-ink-tertiary, #888);
- margin-bottom: 1.5rem;
+ margin-bottom: 2rem;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.brief-content .subtitle::before {
+ content: "📅";
}
-/* 统计卡片 */
+/* 统计卡片增强 */
.brief-content .stats {
display: grid !important;
grid-template-columns: repeat(4, 1fr) !important;
- gap: 12px;
- margin-bottom: 2rem;
+ gap: 16px !important;
+ margin-bottom: 2.5rem;
}
.brief-content .stat-card {
- background: var(--color-page, #f8f9fa) !important;
+ background: linear-gradient(135deg, var(--color-surface) 0%, color-mix(in srgb, var(--color-primary) 3%, var(--color-surface)) 100%) !important;
border: 1px solid var(--color-border, #e2e8f0) !important;
- border-radius: 12px !important;
- padding: 16px !important;
+ border-radius: 14px !important;
+ padding: 20px !important;
text-align: center;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+.brief-content .stat-card:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 8px 20px color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.brief-content .stat-num {
- font-size: 2rem !important;
- font-weight: 800 !important;
- color: var(--color-primary, #6366f1) !important;
+ font-size: 2.25rem !important;
+ font-weight: 900 !important;
+ background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in srgb, var(--color-primary) 80%, black) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
line-height: 1.2;
}
.brief-content .stat-label {
font-size: 0.7rem !important;
color: var(--color-ink-tertiary, #888) !important;
- margin-top: 4px;
+ margin-top: 8px;
text-transform: uppercase;
- letter-spacing: 0.05em;
+ letter-spacing: 0.08em;
+ font-weight: 700;
+}
+
+/* 焦点区域 - 最高优先级 */
+.brief-content .focus-zone {
+ background: linear-gradient(135deg, color-mix(in srgb, var(--color-success) 8%, var(--color-surface)) 0%, color-mix(in srgb, var(--color-success) 12%, var(--color-surface)) 100%) !important;
+ border: 2px solid var(--color-success, #22c55e) !important;
+ border-radius: 18px !important;
+ padding: 24px !important;
+ margin-bottom: 36px !important;
+}
+.brief-content .focus-title {
+ font-size: 1.25rem !important;
+ font-weight: 900 !important;
+ color: color-mix(in srgb, var(--color-success) 80%, black) !important;
+ margin-bottom: 20px !important;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.brief-content .focus-title::before {
+ content: "🎯";
+ font-size: 1.5rem;
+}
+
+/* AI 洞察盒子增强 */
+.brief-content .ai-insight-box {
+ background: var(--color-surface) !important;
+ border-radius: 14px !important;
+ padding: 20px !important;
+ border-left: 5px solid var(--color-success, #22c55e) !important;
+ box-shadow: 0 4px 12px color-mix(in srgb, var(--color-success) 10%, transparent);
+}
+.brief-content .ai-insight-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+.brief-content .ai-insight-icon {
+ font-size: 1.5rem;
+}
+.brief-content .ai-insight-title {
+ font-weight: 800 !important;
+ color: color-mix(in srgb, var(--color-success) 80%, black) !important;
+ font-size: 1rem !important;
+}
+.brief-content .ai-insight-content {
+ font-size: 0.9rem;
+ line-height: 1.9;
+ color: var(--color-ink-secondary, #4b5563);
}
-/* 区块标题 */
+/* 区块标题增强 */
.brief-content .section {
- margin-bottom: 2rem;
+ margin-bottom: 2.5rem;
}
.brief-content .section-title {
- font-size: 1rem !important;
- font-weight: 700 !important;
+ font-size: 1.1rem !important;
+ font-weight: 800 !important;
color: var(--color-ink, #111) !important;
- margin-bottom: 0.75rem;
- padding-bottom: 0.5rem;
- border-bottom: 2px solid var(--color-primary, #6366f1) !important;
+ margin-bottom: 1rem;
+ padding-bottom: 10px;
+ border-bottom: 2px solid var(--color-border, #e2e8f0) !important;
display: flex;
align-items: center;
- gap: 6px;
+ gap: 10px;
+}
+.brief-content .section-title::before {
+ content: "";
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 3px;
+ background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in srgb, var(--color-primary) 70%, black) 100%);
}
-/* 推荐卡片 */
+/* 推荐卡片增强 */
.brief-content .rec-card {
- background: var(--color-surface, #fff) !important;
- border: 1px solid var(--color-border, #e2e8f0) !important;
- border-left: 3px solid var(--color-primary, #6366f1) !important;
- border-radius: 10px !important;
- padding: 14px 16px !important;
- margin-bottom: 10px;
- transition: box-shadow 0.2s;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)) 0%, color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)) 100%) !important;
+ border: 2px solid color-mix(in srgb, var(--color-primary) 30%, var(--color-border)) !important;
+ border-left: 4px solid var(--color-primary) !important;
+ border-radius: 14px !important;
+ padding: 18px !important;
+ margin-bottom: 14px;
+ transition: all 0.2s;
+ cursor: pointer;
}
.brief-content .rec-card:hover {
- box-shadow: 0 2px 8px rgba(0,0,0,0.06);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px color-mix(in srgb, var(--color-primary) 20%, transparent);
+ border-color: var(--color-primary);
}
.brief-content .rec-title {
- font-weight: 600 !important;
- font-size: 0.85rem !important;
+ font-weight: 700 !important;
+ font-size: 0.95rem !important;
color: var(--color-ink, #111) !important;
- line-height: 1.4;
+ line-height: 1.5;
}
.brief-content .rec-meta {
- font-size: 0.7rem !important;
- color: var(--color-ink-tertiary, #999) !important;
- margin-top: 4px;
+ font-size: 0.75rem !important;
+ color: var(--color-ink-tertiary, #6b7280) !important;
+ margin-top: 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
}
.brief-content .rec-reason {
- font-size: 0.8rem !important;
- color: var(--color-ink-secondary, #666) !important;
- margin-top: 6px;
- line-height: 1.5;
+ font-size: 0.85rem !important;
+ color: var(--color-ink-secondary, #4b5563) !important;
+ margin-top: 10px;
+ line-height: 1.7;
+ font-style: italic;
}
-/* 关键词标签 */
+/* 关键词标签增强 */
.brief-content .kw-tag {
display: inline-flex !important;
align-items: center;
- background: var(--color-primary, #6366f1) !important;
- background: color-mix(in srgb, var(--color-primary, #6366f1) 12%, transparent) !important;
- color: var(--color-primary, #6366f1) !important;
+ gap: 5px;
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%) !important;
+ color: #92400e !important;
border-radius: 9999px !important;
- padding: 4px 12px !important;
- font-size: 0.72rem !important;
- font-weight: 500 !important;
- margin: 3px !important;
- border: 1px solid color-mix(in srgb, var(--color-primary, #6366f1) 20%, transparent);
+ padding: 7px 14px !important;
+ font-size: 0.8rem !important;
+ font-weight: 700 !important;
+ margin: 5px !important;
+ border: 2px solid #f59e0b !important;
+ transition: transform 0.2s;
+}
+.brief-content .kw-tag:hover {
+ transform: scale(1.08);
+}
+.brief-content .kw-tag::before {
+ content: "🔥";
+ font-size: 0.9rem;
}
-/* 论文卡片 */
+/* 主题分组增强 */
+.brief-content .topic-group {
+ margin-bottom: 28px;
+ background: var(--color-surface) !important;
+ border-radius: 14px !important;
+ padding: 18px !important;
+ border: 1px solid var(--color-border, #e2e8f0) !important;
+}
+.brief-content .topic-name {
+ font-size: 0.95rem !important;
+ font-weight: 800 !important;
+ color: var(--color-primary, #6366f1) !important;
+ margin-bottom: 14px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding-bottom: 10px;
+ border-bottom: 1px dashed var(--color-border, #e2e8f0) !important;
+}
+.brief-content .topic-name::before {
+ content: "📁";
+ font-size: 1rem;
+}
+
+/* 论文卡片增强 */
.brief-content .paper-item {
background: var(--color-surface, #fff) !important;
- border: 1px solid var(--color-border, #e2e8f0) !important;
- border-radius: 10px !important;
- padding: 14px 16px !important;
- margin-bottom: 8px;
- transition: border-color 0.2s;
+ border: 1.5px solid var(--color-border, #e2e8f0) !important;
+ border-radius: 12px !important;
+ padding: 16px !important;
+ margin-bottom: 12px;
+ transition: all 0.2s;
+ cursor: pointer;
}
.brief-content .paper-item:hover {
- border-color: color-mix(in srgb, var(--color-primary, #6366f1) 40%, var(--color-border, #e2e8f0));
+ border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
+ box-shadow: 0 4px 12px color-mix(in srgb, var(--color-primary) 10%, transparent);
+ transform: translateY(-2px);
+}
+.brief-content .paper-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 10px;
}
.brief-content .paper-title {
- font-weight: 600 !important;
- font-size: 0.85rem !important;
+ font-weight: 700 !important;
+ font-size: 0.9rem !important;
color: var(--color-ink, #111) !important;
- line-height: 1.4;
+ line-height: 1.5;
}
.brief-content .paper-id {
- font-size: 0.65rem !important;
- color: var(--color-ink-tertiary, #aaa) !important;
- margin-top: 3px;
+ font-size: 0.7rem !important;
+ color: var(--color-ink-tertiary, #9ca3af) !important;
font-family: ui-monospace, monospace !important;
+ margin-bottom: 8px;
}
.brief-content .paper-summary {
- font-size: 0.8rem !important;
- color: var(--color-ink-secondary, #555) !important;
- margin-top: 8px !important;
- line-height: 1.6;
- white-space: pre-wrap;
-}
-
-/* 主题分组 */
-.brief-content .topic-group {
- margin-bottom: 1.5rem;
-}
-.brief-content .topic-name {
font-size: 0.85rem !important;
- font-weight: 700 !important;
- color: var(--color-primary, #6366f1) !important;
- margin-bottom: 8px;
- display: flex;
- align-items: center;
- gap: 6px;
-}
-.brief-content .topic-name::before {
- content: "";
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--color-primary, #6366f1);
+ color: var(--color-ink-secondary, #6b7280) !important;
+ margin-top: 10px !important;
+ line-height: 1.7;
+ max-height: 65px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ transition: max-height 0.3s;
}
-
-/* 页脚 */
-.brief-content .footer {
- text-align: center;
- color: var(--color-ink-tertiary, #aaa) !important;
- font-size: 0.7rem !important;
- margin-top: 2.5rem;
- padding-top: 1rem;
- border-top: 1px solid var(--color-border, #e2e8f0) !important;
+.brief-content .paper-item:hover .paper-summary {
+ max-height: 300px;
}
-/* Deep read cards */
+/* Deep read cards 增强 */
.brief-content .deep-card {
- background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)) 0%, color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)) 100%) !important;
- border: 1px solid color-mix(in srgb, var(--color-primary) 30%, var(--color-border)) !important;
- border-left: 4px solid var(--color-primary) !important;
- border-radius: 12px !important;
- padding: 18px !important;
- margin-bottom: 14px;
- transition: box-shadow 0.2s;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-surface)) 0%, color-mix(in srgb, var(--color-primary) 15%, var(--color-surface)) 100%) !important;
+ border: 2px solid color-mix(in srgb, var(--color-primary) 40%, var(--color-border)) !important;
+ border-left: 5px solid var(--color-primary) !important;
+ border-radius: 16px !important;
+ padding: 20px !important;
+ margin-bottom: 18px;
+ transition: all 0.2s;
+ cursor: pointer;
}
.brief-content .deep-card:hover {
- box-shadow: 0 4px 12px rgba(99, 102, 241, 0.12);
+ transform: translateY(-3px);
+ box-shadow: 0 8px 20px color-mix(in srgb, var(--color-primary) 20%, transparent);
+ border-color: var(--color-primary);
+}
+.brief-content .deep-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ margin-bottom: 14px;
}
.brief-content .deep-title {
- font-weight: 700 !important;
- font-size: 0.9rem !important;
+ font-weight: 800 !important;
+ font-size: 1rem !important;
color: var(--color-ink) !important;
+ line-height: 1.4;
}
.brief-content .deep-section {
- margin-top: 10px !important;
+ margin-top: 14px !important;
}
.brief-content .deep-section-label {
font-size: 0.7rem !important;
- font-weight: 600 !important;
+ font-weight: 800 !important;
color: var(--color-primary) !important;
- margin-bottom: 4px !important;
+ margin-bottom: 6px !important;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
}
.brief-content .deep-text {
- font-size: 0.8rem !important;
+ font-size: 0.85rem !important;
color: var(--color-ink-secondary) !important;
- line-height: 1.6;
+ line-height: 1.7;
margin: 0 !important;
}
.brief-content .risk-list {
- margin: 4px 0 0 16px !important;
+ margin: 8px 0 0 20px !important;
padding: 0 !important;
- font-size: 0.72rem !important;
- color: #b45309 !important;
+ font-size: 0.75rem !important;
+ color: color-mix(in srgb, #f59e0b 80%, black) !important;
}
.brief-content .risk-list li {
- margin-bottom: 2px;
+ margin-bottom: 5px;
+ line-height: 1.5;
}
-/* Score badges */
+/* 分数徽章增强 */
.brief-content .score-badge {
- font-weight: 700 !important;
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
border-radius: 9999px !important;
- white-space: nowrap;
-}
-.brief-content .score-sm {
- font-size: 0.65rem !important;
- padding: 1px 6px !important;
+ font-weight: 800 !important;
+ font-size: 0.7rem !important;
+ padding: 4px 10px !important;
+ min-width: 52px;
+ border: 1.5px solid transparent;
}
.brief-content .score-high {
- background: #dcfce7 !important;
- color: #15803d !important;
+ background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%) !important;
+ color: #166534 !important;
+ border-color: #22c55e !important;
}
.brief-content .score-mid {
- background: #fef3c7 !important;
- color: #b45309 !important;
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%) !important;
+ color: #92400e !important;
+ border-color: #f59e0b !important;
}
.brief-content .score-low {
- background: #fee2e2 !important;
- color: #dc2626 !important;
+ background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%) !important;
+ color: #991b1b !important;
+ border-color: #ef4444 !important;
+}
+
+/* Deep badge 增强 */
+.brief-content .deep-badge {
+ display: inline-flex !important;
+ align-items: center;
+ gap: 3px;
+ background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%) !important;
+ color: #6d28d9 !important;
+ padding: 3px 10px !important;
+ border-radius: 8px !important;
+ font-size: 0.65rem !important;
+ font-weight: 800 !important;
+ border: 1.5px solid #a855f7 !important;
+}
+.brief-content .deep-badge::before {
+ content: "✨";
+ font-size: 0.7rem;
}
-/* Innovation tags */
+/* 创新标签增强 */
.brief-content .innovation-tags {
display: flex !important;
flex-wrap: wrap !important;
- gap: 4px !important;
- margin-top: 6px !important;
+ gap: 8px !important;
+ margin-top: 10px !important;
}
.brief-content .innovation-tag {
+ display: inline-flex !important;
+ align-items: center;
+ gap: 4px;
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%) !important;
+ color: #78350f !important;
+ border-radius: 10px !important;
+ padding: 5px 12px !important;
+ font-size: 0.7rem !important;
+ font-weight: 700 !important;
+ border: 1.5px solid #f59e0b !important;
+}
+.brief-content .innovation-tag::before {
+ content: "💡";
+ font-size: 0.8rem;
+}
+
+/* 按钮增强 */
+.brief-content .btn {
display: inline-block !important;
- background: color-mix(in srgb, #f59e0b 12%, transparent) !important;
- color: #92400e !important;
- border-radius: 6px !important;
- padding: 3px 8px !important;
- font-size: 0.68rem !important;
+ padding: 9px 18px !important;
+ background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in srgb, var(--color-primary) 80%, black) 100%) !important;
+ color: #fff !important;
+ text-decoration: none !important;
+ border-radius: 10px !important;
+ font-size: 0.75rem !important;
+ font-weight: 700 !important;
+ margin-top: 10px !important;
+ transition: all 0.2s;
+ border: none;
+ cursor: pointer;
+}
+.brief-content .btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
-/* Deep badge */
-.brief-content .deep-badge {
- background: color-mix(in srgb, var(--color-primary) 15%, transparent) !important;
+/* 页脚 */
+.brief-content .footer {
+ text-align: center;
+ color: var(--color-ink-tertiary, #9ca3af) !important;
+ font-size: 0.75rem !important;
+ margin-top: 56px;
+ padding-top: 24px;
+ border-top: 2px solid var(--color-border, #e2e8f0) !important;
+}
+.brief-content .footer a {
color: var(--color-primary) !important;
- padding: 1px 6px !important;
- border-radius: 4px !important;
- font-size: 0.6rem !important;
- font-weight: 600 !important;
+ text-decoration: none;
+ font-weight: 700;
}
-
-/* Paper header */
-.brief-content .paper-header {
- display: flex !important;
- align-items: flex-start !important;
- justify-content: space-between !important;
- gap: 8px !important;
+.brief-content .footer a:hover {
+ text-decoration: underline;
}
-/* 暗色模式 */
+/* 暗色模式增强 */
:root.dark .brief-content,
.dark .brief-content {
color: var(--color-ink, #e2e8f0);
}
.dark .brief-content .stat-card {
- background: var(--color-surface, #1e1e2e) !important;
+ background: linear-gradient(135deg, var(--color-surface, #1e1e2e) 0%, color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)) 100%) !important;
border-color: var(--color-border, #333) !important;
}
-.dark .brief-content .rec-card {
- background: var(--color-surface, #1e1e2e) !important;
- border-color: var(--color-border, #333) !important;
+.dark .brief-content .focus-zone {
+ background: linear-gradient(135deg, color-mix(in srgb, var(--color-success) 12%, var(--color-surface)) 0%, color-mix(in srgb, var(--color-success) 18%, var(--color-surface)) 100%) !important;
+ border-color: color-mix(in srgb, var(--color-success) 60%, var(--color-border)) !important;
}
-.dark .brief-content .paper-item {
+.dark .brief-content .ai-insight-box,
+.dark .brief-content .rec-card,
+.dark .brief-content .paper-item,
+.dark .brief-content .topic-group {
background: var(--color-surface, #1e1e2e) !important;
border-color: var(--color-border, #333) !important;
}
-.dark .brief-content .rec-card:hover,
-.dark .brief-content .paper-item:hover,
-.dark .brief-content .deep-card:hover {
- box-shadow: 0 2px 8px rgba(0,0,0,0.3);
-}
.dark .brief-content .deep-card {
- background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)) 0%, color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)) 100%) !important;
- border-color: color-mix(in srgb, var(--color-primary) 30%, var(--color-border)) !important;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 15%, var(--color-surface)) 0%, color-mix(in srgb, var(--color-primary) 22%, var(--color-surface)) 100%) !important;
+ border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border)) !important;
}
-.dark .brief-content .deep-card:hover {
- box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
+.dark .brief-content .kw-tag {
+ background: linear-gradient(135deg, color-mix(in srgb, #f59e0b 20%, transparent) 0%, color-mix(in srgb, #f59e0b 30%, transparent) 100%) !important;
+ color: #fbbf24 !important;
+ border-color: #f59e0b !important;
}
.dark .brief-content .innovation-tag {
- background: color-mix(in srgb, #f59e0b 15%, transparent) !important;
+ background: linear-gradient(135deg, color-mix(in srgb, #f59e0b 18%, transparent) 0%, color-mix(in srgb, #f59e0b 28%, transparent) 100%) !important;
color: #fbbf24 !important;
}
.dark .brief-content .score-high {
- background: #052e16 !important;
+ background: linear-gradient(135deg, #052e16 0%, #064e3b 100%) !important;
color: #4ade80 !important;
+ border-color: #22c55e !important;
}
.dark .brief-content .score-mid {
- background: #451a03 !important;
+ background: linear-gradient(135deg, #451a03 0%, #78350f 100%) !important;
color: #fbbf24 !important;
+ border-color: #f59e0b !important;
}
.dark .brief-content .score-low {
- background: #450a0a !important;
+ background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 100%) !important;
color: #f87171 !important;
+ border-color: #ef4444 !important;
+}
+.dark .brief-content .deep-badge {
+ background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 20%, transparent) 0%, color-mix(in srgb, var(--color-primary) 30%, transparent) 100%) !important;
+ color: #c4b5fd !important;
+ border-color: #a855f7 !important;
}
.dark .brief-content .risk-list {
color: #fbbf24 !important;
}
+.dark .brief-content .rec-card:hover,
+.dark .brief-content .paper-item:hover,
+.dark .brief-content .deep-card:hover,
+.dark .brief-content .stat-card:hover {
+ box-shadow: 0 6px 20px rgba(0,0,0,0.4);
+}
`;
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 7df0335..e60b5ed 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -80,6 +80,7 @@ export default function Dashboard() {
const [today, setToday] = useState
(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const [costDays, setCostDays] = useState(7);
async function loadData() {
setLoading(true);
@@ -87,7 +88,7 @@ export default function Dashboard() {
try {
const [s, c, r, t] = await Promise.all([
systemApi.status(),
- metricsApi.costs(7),
+ metricsApi.costs(costDays),
pipelineApi.runs(10),
todayApi.summary().catch(() => null),
]);
@@ -102,17 +103,21 @@ export default function Dashboard() {
}
}
- useEffect(() => { loadData(); }, []);
+ useEffect(() => {
+ loadData();
+ }, [costDays]);
if (loading) return ;
if (error) {
return (
-
-
+
+
-
{error}
-
重试
+
{error}
+
+ 重试
+
);
}
@@ -120,7 +125,7 @@ export default function Dashboard() {
const isHealthy = status?.health?.status === "ok";
const todayNew = today?.today_new ?? 0;
const weekNew = today?.week_new ?? 0;
- const totalPapers = today?.total_papers ?? (status?.counts?.papers_latest_200 ?? 0);
+ const totalPapers = today?.total_papers ?? status?.counts?.papers_latest_200 ?? 0;
return (
@@ -128,22 +133,26 @@ export default function Dashboard() {
-
+
-
Dashboard
-
系统总览与运行状态
+
Dashboard
+
系统总览与运行状态
{hasRunning && (
-
+
{activeTasks.length} 个任务运行中
)}
-
+
{isHealthy ? "系统正常" : "系统异常"}
@@ -187,7 +196,7 @@ export default function Dashboard() {
/>
}
- label="7日 Token"
+ label={costDays > 0 ? `${costDays}日 Token` : "历史 Token"}
value={fmtTokens((costs?.input_tokens ?? 0) + (costs?.output_tokens ?? 0))}
sub={`${costs?.calls ?? 0} 次调用`}
color="success"
@@ -199,97 +208,147 @@ export default function Dashboard() {
{/* 左侧:成本分析 + 活动记录 */}
{/* Token 用量分析 */}
-
}>
+
}
+ action={
+
+ {[
+ { label: "1d", days: 1 },
+ { label: "7d", days: 7 },
+ { label: "30d", days: 30 },
+ { label: "历史", days: 0 },
+ ].map((opt) => (
+ setCostDays(opt.days)}
+ className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
+ costDays === opt.days
+ ? "bg-primary text-white"
+ : "text-ink-tertiary hover:text-ink"
+ }`}
+ >
+ {opt.label}
+
+ ))}
+
+ }
+ >
{costs && costs.by_stage.length > 0 ? (
{/* 总量概览 */}
-
-
{fmtTokens((costs.input_tokens ?? 0) + (costs.output_tokens ?? 0))}
-
总 Token
+
+
+ {fmtTokens((costs.input_tokens ?? 0) + (costs.output_tokens ?? 0))}
+
+
总 Token
-
-
{fmtTokens(costs.input_tokens ?? 0)}
-
输入
+
+
+ {fmtTokens(costs.input_tokens ?? 0)}
+
+
输入
-
-
{fmtTokens(costs.output_tokens ?? 0)}
-
输出
+
+
+ {fmtTokens(costs.output_tokens ?? 0)}
+
+
输出
{/* 按阶段 */}
-
按阶段
+
+ 按阶段
+
{costs.by_stage.map((s) => {
const stageTotal = (s.input_tokens ?? 0) + (s.output_tokens ?? 0);
- const maxTokens = Math.max(...costs.by_stage.map((x) => (x.input_tokens ?? 0) + (x.output_tokens ?? 0)), 1);
+ const maxTokens = Math.max(
+ ...costs.by_stage.map((x) => (x.input_tokens ?? 0) + (x.output_tokens ?? 0)),
+ 1
+ );
const pct = Math.max((stageTotal / maxTokens) * 100, 3);
return (
-
- {STAGE_LABELS[s.stage] || s.stage}
+
+
+ {STAGE_LABELS[s.stage] || s.stage}
+
- {fmtTokens(stageTotal)}
- {s.calls}次
+
+ {fmtTokens(stageTotal)}
+
+ {s.calls}次
-
+
0 ? Math.max(((s.input_tokens ?? 0) / maxTokens) * 100, 1) : 1}%` }}
+ className="bar-animate bg-info/70 h-full rounded-l-full"
+ style={{
+ width: `${maxTokens > 0 ? Math.max(((s.input_tokens ?? 0) / maxTokens) * 100, 1) : 1}%`,
+ }}
/>
0 ? Math.max(((s.output_tokens ?? 0) / maxTokens) * 100, 1) : 1}%` }}
+ className="bar-animate bg-warning/70 h-full rounded-r-full"
+ style={{
+ width: `${maxTokens > 0 ? Math.max(((s.output_tokens ?? 0) / maxTokens) * 100, 1) : 1}%`,
+ }}
/>
);
})}
-
-
输入
-
输出
+
+
+
+ 输入
+
+
+
+ 输出
+
) : (
-
暂无 Token 数据
+
暂无 Token 数据
)}
{/* 最近活动 */}
-
}>
+
}>
{runs.length > 0 ? (
{runs.map((run, index) => (
navigate("/pipelines")}
>
-
+
{index + 1}
-
+
{PIPELINE_LABELS[run.pipeline_name] || run.pipeline_name}
{run.elapsed_ms != null && (
-
+
{formatDuration(run.elapsed_ms)}
)}
-
+
{timeAgo(run.created_at)}
{run.error_message && (
- {run.error_message}
+ {run.error_message}
)}
@@ -298,9 +357,9 @@ export default function Dashboard() {
) : (
-
-
暂无活动记录
-
运行任务后会在这里显示
+
+
暂无活动记录
+
运行任务后会在这里显示
)}
@@ -312,31 +371,36 @@ export default function Dashboard() {
{hasRunning && activeTasks.length > 0 && (
}
+ icon={}
>
{activeTasks.slice(0, 3).map((task) => (
-
+
-
{task.title}
- {task.progress_pct}%
+
+ {task.title}
+
+
+ {task.progress_pct}%
+
-
+
-
{task.message}
+
{task.message}
{task.elapsed_seconds > 0 && (
-
- {Math.floor(task.elapsed_seconds / 60)}:{(task.elapsed_seconds % 60).toString().padStart(2, '0')}
+
+ {Math.floor(task.elapsed_seconds / 60)}:
+ {(task.elapsed_seconds % 60).toString().padStart(2, "0")}
)}
))}
{activeTasks.length > 3 && (
-
+
还有 {activeTasks.length - 3} 个任务...
)}
@@ -346,7 +410,7 @@ export default function Dashboard() {
{/* 推荐论文 */}
{today && today.recommendations.length > 0 && (
-
}>
+
}>
{today.recommendations.slice(0, 4).map((rec) => (
navigate(`/papers/${rec.id}`)}
className="block w-full text-left"
>
-
-
+
+
{rec.title_zh || rec.title}
-
+
相似度 {(rec.similarity * 100).toFixed(0)}%
@@ -369,19 +433,31 @@ export default function Dashboard() {
)}
-
);
}
/* ========== 子组件 ========== */
-function SectionCard({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) {
+function SectionCard({
+ title,
+ icon,
+ action,
+ children,
+}: {
+ title: string;
+ icon: React.ReactNode;
+ action?: React.ReactNode;
+ children: React.ReactNode;
+}) {
return (
-
-
- {icon}
-
{title}
+
+
+
+ {icon}
+
{title}
+
+ {action}
{children}
@@ -389,7 +465,12 @@ function SectionCard({ title, icon, children }: { title: string; icon: React.Rea
}
function StatCard({
- icon, label, value, sub, color, onClick,
+ icon,
+ label,
+ value,
+ sub,
+ color,
+ onClick,
}: {
icon: React.ReactNode;
label: string;
@@ -408,15 +489,19 @@ function StatCard({
return (
-
{icon}
- {onClick &&
}
+
+ {icon}
+
+ {onClick && (
+
+ )}
- {value}
- {label}
- {sub}
+ {value}
+ {label}
+ {sub}
);
}
diff --git a/frontend/src/pages/EmailSettings.tsx b/frontend/src/pages/EmailSettings.tsx
index 72cfe2d..d7b7d05 100644
--- a/frontend/src/pages/EmailSettings.tsx
+++ b/frontend/src/pages/EmailSettings.tsx
@@ -11,10 +11,7 @@ import { Badge } from "@/components/ui/Badge";
import { Spinner } from "@/components/ui/Spinner";
import { Empty } from "@/components/ui/Empty";
import { Modal } from "@/components/ui/Modal";
-import {
- emailConfigApi,
- dailyReportApi,
-} from "@/services/api";
+import { emailConfigApi, dailyReportApi } from "@/services/api";
import type { EmailConfig, EmailConfigForm, DailyReportConfig } from "@/types";
import { getErrorMessage } from "@/lib/errorHandler";
import {
@@ -36,11 +33,19 @@ import {
AlertCircle,
} from "lucide-react";
-const SMTP_PRESETS: Record
= {
+const SMTP_PRESETS: Record<
+ string,
+ { label: string; smtp_server: string; smtp_port: number; smtp_use_tls: boolean }
+> = {
gmail: { label: "Gmail", smtp_server: "smtp.gmail.com", smtp_port: 587, smtp_use_tls: true },
qq: { label: "QQ邮箱", smtp_server: "smtp.qq.com", smtp_port: 587, smtp_use_tls: true },
"163": { label: "163邮箱", smtp_server: "smtp.163.com", smtp_port: 465, smtp_use_tls: true },
- outlook: { label: "Outlook", smtp_server: "smtp-mail.outlook.com", smtp_port: 587, smtp_use_tls: true },
+ outlook: {
+ label: "Outlook",
+ smtp_server: "smtp-mail.outlook.com",
+ smtp_port: 587,
+ smtp_use_tls: true,
+ },
};
export default function EmailSettings() {
@@ -188,16 +193,16 @@ export default function EmailSettings() {
if (loading) {
return (
-
+
);
}
return (
-
+
-
+
邮箱与每日报告设置
@@ -207,26 +212,18 @@ export default function EmailSettings() {
{/* 每日报告配置 */}
-
+
-
+
-
- 每日报告配置
-
-
- 自动精读论文并发送邮件报告
-
+
每日报告配置
+
自动精读论文并发送邮件报告
-
-
+
+
立即执行
@@ -234,9 +231,11 @@ export default function EmailSettings() {
{dailyConfig && (
{/* 总开关 */}
-
+
-
+
{dailyConfig.enabled ? (
) : (
@@ -263,31 +262,41 @@ export default function EmailSettings() {
{/* 详细配置 */}
{dailyConfig.enabled && (
-
+
{/* 自动精读设置 */}
-
+
自动精读设置
- 每日精读数量限制
+
+ 每日精读数量限制
+
handleUpdateDailyConfig({ deep_read_limit: parseInt(e.target.value) || 10 })}
+ onChange={(e) =>
+ handleUpdateDailyConfig({
+ deep_read_limit: parseInt(e.target.value) || 10,
+ })
+ }
className="w-20"
/>
@@ -296,7 +305,7 @@ export default function EmailSettings() {
{/* 邮件发送设置 */}
-
+
邮件发送设置
@@ -306,28 +315,45 @@ export default function EmailSettings() {
handleUpdateDailyConfig({ send_email_report: e.target.checked })}
- className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500"
+ onChange={(e) =>
+ handleUpdateDailyConfig({ send_email_report: e.target.checked })
+ }
+ className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
/>
- 收件人邮箱(逗号分隔)
+
+ 收件人邮箱(逗号分隔)
+
handleUpdateDailyConfig({ recipient_emails: e.target.value.split(",").map(e => e.trim()).filter(Boolean) })}
+ onChange={(e) =>
+ handleUpdateDailyConfig({
+ recipient_emails: e.target.value
+ .split(",")
+ .map((e) => e.trim())
+ .filter(Boolean),
+ })
+ }
className="w-full"
/>
- 发送时间(UTC)
+
+ 发送时间(UTC)
+
handleUpdateDailyConfig({ report_time_utc: parseInt(e.target.value) || 21 })}
+ onChange={(e) =>
+ handleUpdateDailyConfig({
+ report_time_utc: parseInt(e.target.value) || 21,
+ })
+ }
className="w-20"
/>
@@ -336,7 +362,7 @@ export default function EmailSettings() {
{/* 报告内容设置 */}
-
+
报告内容设置
@@ -346,8 +372,10 @@ export default function EmailSettings() {
handleUpdateDailyConfig({ include_paper_details: e.target.checked })}
- className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500"
+ onChange={(e) =>
+ handleUpdateDailyConfig({ include_paper_details: e.target.checked })
+ }
+ className="h-4 w-4 rounded text-purple-600 focus:ring-purple-500"
/>
@@ -369,22 +399,20 @@ export default function EmailSettings() {
{/* 邮箱配置列表 */}
-
+
-
+
-
- 邮箱配置
-
+
邮箱配置
配置 SMTP 服务器用于发送邮件报告
setEmailModalOpen(true)} size="sm">
-
+
添加邮箱
@@ -396,20 +424,20 @@ export default function EmailSettings() {
description="添加邮箱配置后才能发送每日报告"
/>
) : (
-
+
{emailConfigs.map((config) => (
-
+
-
- {config.name}
-
+ {config.name}
{config.is_active && (
- 已激活
+
+ 已激活
+
)}
@@ -432,8 +460,12 @@ export default function EmailSettings() {
-
发送方: {config.sender_name} <{config.sender_email}>
-
SMTP: {config.smtp_server}:{config.smtp_port}
+
+ 发送方: {config.sender_name} <{config.sender_email}>
+
+
+ SMTP: {config.smtp_server}:{config.smtp_port}
+
) : (
<>
-
+
发送测试
>
)}
@@ -474,7 +506,7 @@ export default function EmailSettings() {
{/* SMTP 预设 */}
-