diff --git a/frontend/src/app/daily/_frontpage.tsx b/frontend/src/app/daily/_frontpage.tsx new file mode 100644 index 00000000..7a871ca0 --- /dev/null +++ b/frontend/src/app/daily/_frontpage.tsx @@ -0,0 +1,475 @@ +'use client'; + +/** + * 晨报头版组件(issue #119 日报改版 v1)。 + * + * 报纸语法的头版结构(R1-R8 圆桌定案、demo 验证后落地): + * - Masthead:刊名 + 双细线 + 日期行(期号/版次/出刊时刻)+ 统计行(昨日兑现/长度合同) + * - 导语块:takeaway(衬线导语)+ overview(主编判断,左边线) + * - LeadPickCard:头题全展(引题/大标题/主判/证据行/还热行/角度/避坑/动作) + * - SecondPickRow:二条窄栏(分类/▲↑↓/标题/一行理由/还热,点击展开) + * - ColophonStrip:中缝(昨日对账摘要 + 导流) + * + * 问责按 R8 校准做轻量:一行昨日兑现、「这题什么时候凉」随题走、▲↑↓ 变化标记。 + */ + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowRight, BookOpen, Clock, ExternalLink, Flame } from 'lucide-react'; +import { cx } from '@/components/ui'; +import Sparkline from '@/components/Sparkline'; +import type { SparklineData } from '@/components/Sparkline'; +import { + type DailyPick, + type MarkAction, + LIFECYCLE_META, + CATEGORY_EN, + isEnglishTitle, + displaySourceTitle, + pickKey, +} from './_daily-utils'; + + +/** 「写这个」:带选题上下文跳创作计划页(与 PickCard 的链接同参)。 */ +export function useWritePick() { + const router = useRouter(); + return (pick: PickForWrite) => { + const title = encodeURIComponent(pick.source_title_zh || pick.source_title || pick.title || ''); + const url = pick.source_url ? `&url=${encodeURIComponent(pick.source_url)}` : ''; + router.push(`/plan?title=${title}${url}`); + }; +} + +interface PickForWrite { + title?: string; + source_title?: string; + source_title_zh?: string; + source_url?: string; +} + +// ── 变化标记(▲新入 / ↑涨 / →稳):与昨日日报 diff 得出 ────────────── + +export type PickChangeMarker = 'new' | 'up' | 'flat'; + +export function computeChangeMarkers( + picks: DailyPick[], + yesterdayPicks: Array<{ source_title: string; old_score: number | null }> | undefined, +): Record { + const markers: Record = {}; + if (!yesterdayPicks) return markers; + const yesterday = new Map(yesterdayPicks.map((p) => [p.source_title, p.old_score])); + for (const pick of picks) { + const key = pickKey(pick); + if (!key) continue; + const oldScore = yesterday.get(key); + if (oldScore == null) { + markers[key] = 'new'; + } else if (pick.score != null && pick.score > oldScore + 1) { + markers[key] = 'up'; + } else { + markers[key] = 'flat'; + } + } + return markers; +} + +const CHANGE_META: Record = { + new: { label: '▲新入', cls: 'text-teal bg-teal-light' }, + up: { label: '↑涨', cls: 'text-amber bg-amber-light' }, + flat: { label: '→稳', cls: 'text-gray-400 bg-gray-100' }, +}; + +export function ChangeMarkerBadge({ marker }: { marker?: PickChangeMarker }) { + if (!marker) return null; + const meta = CHANGE_META[marker]; + return {meta.label}; +} + +// ── 「这题什么时候凉」:lifecycle + time_window 的创作者翻译 ────────── + +export function coolDownText(pick: PickLifecycle & { time_window?: string }): string { + const tw = pick.time_window?.trim(); + switch (pick.lifecycle) { + case '上升期': + return tw ? `还在升温,${tw}` : '还在升温,建议尽快进场'; + case '见顶': + return tw ? `热度见顶,${tw}` : '热度见顶,建议收尾'; + case '退潮': + return '正在退潮,不建议新进场'; + default: + return tw || ''; + } +} + +interface PickLifecycle { + lifecycle?: string; +} + +// ── 报头(Masthead)────────────────────────────────────────────────── + +export function Masthead({ + reportDate, + weekday, + editionLabel, + issueNumber, + generatedAt, + yesterdayLine, + pickCount, + readMinutes, +}: { + reportDate: string; + weekday: string; + editionLabel?: string; + issueNumber?: number; + generatedAt?: string; + yesterdayLine?: string; + pickCount: number; + readMinutes: number; +}) { + return ( +
+

TopicEye · 全行业信息日报

+

+ 选题雷达 +

+
+
+ {reportDate} {weekday} + {editionLabel && · {editionLabel}} + {issueNumber != null && · 第 {issueNumber} 期} + + {generatedAt && <>数据截至 {generatedAt}} + +
+
+ {yesterdayLine && {yesterdayLine}} + + 今日 {pickCount} 题 + + 头版约 1 分钟 · 完整版约 {readMinutes} 分钟 +
+
+ ); +} + +// ── 导语块(takeaway + 主编判断)───────────────────────────────────── + +export function LeadIn({ takeaway, overview }: { takeaway?: string | null; overview?: string | null }) { + if (!takeaway && !overview) return null; + return ( +
+ {(takeaway || overview) && ( +

+ {takeaway || overview?.slice(0, 60)} +

+ )} + {overview && takeaway && ( +
+ 主编判断 + {overview} +
+ )} +
+ ); +} + +// ── 头题卡(头版全展)──────────────────────────────────────────────── + +export function LeadPickCard({ + pick, + sparklineData, + markAction, + showOriginalLang, + onToggleLang, + onMark, + onOpenReader, + onWrite, +}: { + pick: DailyPick; + sparklineData?: SparklineData; + markAction?: MarkAction; + showOriginalLang: boolean; + onToggleLang: () => void; + onMark: (title: string, action: MarkAction, category?: string, sourceUrl?: string) => void; + onOpenReader: (contentId: number, url?: string) => void; + onWrite: (pick: DailyPick) => void; +}) { + const key = pickKey(pick); + const lifecycleMeta = pick.lifecycle ? LIFECYCLE_META[pick.lifecycle] : undefined; + const sourceTitle = displaySourceTitle(pick, showOriginalLang); + const cool = coolDownText(pick); + + return ( +
+ {/* 引题行 */} +
+ + {pick.category || '精选选题'} + + {pick.score != null && ( + 综合 {pick.score} + )} + {lifecycleMeta && ( + + {lifecycleMeta.label} + + )} +
+ + {/* 大标题 + 副题(主判) */} +

+ {pick.editorial_title || pick.title} +

+

{pick.reason}

+ + {/* 证据行:原文 + 热度曲线 + 还热判断 */} +
+ {sourceTitle && ( + + )} + {sparklineData && sparklineData.points.length > 0 && ( + + )} + {cool && ( + + + 这题什么时候凉:{cool} + + )} + +
+ + {/* 可写角度 */} + {pick.angles && pick.angles.length > 0 && ( +
+ 可写角度 + {pick.angles.map((angle, i) => ( + + {angle} + + ))} +
+ )} + + {/* 避坑提示 */} + {pick.pitfall && ( +
+ ⚠ {pick.pitfall} +
+ )} + + {/* 动作带 */} +
+ + + +
+
+ ); +} + +// ── 二条窄栏(点击展开)────────────────────────────────────────────── + +export function SecondPickRow({ + pick, + rank, + marker, + expanded, + onToggle, + markAction, + onMark, + onOpenReader, + onWrite, +}: { + pick: DailyPick; + rank: number; + marker?: PickChangeMarker; + expanded: boolean; + onToggle: () => void; + markAction?: MarkAction; + onMark: (title: string, action: MarkAction, category?: string, sourceUrl?: string) => void; + onOpenReader: (contentId: number, url?: string) => void; + onWrite: (pick: DailyPick) => void; +}) { + const key = pickKey(pick); + const cool = coolDownText(pick); + + return ( +
+ + + {expanded && ( +
+

{pick.reason}

+ {pick.angles && pick.angles.length > 0 && ( +
+ 可写角度 + {pick.angles.map((angle, i) => ( + + {angle} + + ))} +
+ )} + {pick.pitfall && ( +
+ ⚠ {pick.pitfall} +
+ )} + {pick.source_title && ( + + )} +
+ + + +
+
+ )} +
+ ); +} + +// ── 中缝(昨日对账 + 导流)─────────────────────────────────────────── + +export function ColophonStrip({ + yesterdaySummary, +}: { + yesterdaySummary?: { confirmed: number; reversed: number; persisted: number; dropped: number } | null; +}) { + return ( +
+ {yesterdaySummary ? ( + + 昨日对账: + 兑现 {yesterdaySummary.confirmed} · + 反转 {yesterdaySummary.reversed} · + 仍在榜 {yesterdaySummary.persisted} · + 掉出 {yesterdaySummary.dropped} + + ) : ( + 昨日对账:首期或数据不足 + )} + | + 趋势实验室 → + 今日精选 → +
+ ); +} + +// ── 趋势一行(增刊归还:缩为一行跳 /trends)───────────────────────── + +export function TrendsOneLiner({ + trends, +}: { + trends: Array<{ title: string; momentum?: string }>; +}) { + if (trends.length === 0) return null; + return ( +
+ + 趋势信号 + {trends.slice(0, 3).map((t, i) => ( + + + {t.momentum === 'up' ? '↑' : t.momentum === 'down' ? '↓' : '→'} + + {t.title} + + ))} + 趋势实验室 → +
+ ); +} diff --git a/frontend/src/app/daily/page.tsx b/frontend/src/app/daily/page.tsx index 1189d4bc..3c73ee1b 100644 --- a/frontend/src/app/daily/page.tsx +++ b/frontend/src/app/daily/page.tsx @@ -15,10 +15,9 @@ import { Send, Smartphone, Target, - TrendingUp, KeyRound, } from 'lucide-react'; -import { Panel, cx } from '@/components/ui'; +import { cx } from '@/components/ui'; import { useAppContext } from '@/components/ClientLayout'; import { isAdmin } from '@/lib/navigation'; import { dailyReportApi } from '@/lib/api'; @@ -33,7 +32,17 @@ import { ReportSectionTitle, ReportStatusPanel, } from '@/components/ReportLayout'; -import { PickCard, BriefPickRow } from './_components'; +import { BriefPickRow } from './_components'; +import { + Masthead, + LeadIn, + LeadPickCard, + SecondPickRow, + ColophonStrip, + TrendsOneLiner, + computeChangeMarkers, + useWritePick, +} from './_frontpage'; import { type DailyReportData, type DateSummary, @@ -50,6 +59,7 @@ import { marksMapFromResp, groupByCategory, } from './_daily-utils'; +import type { YesterdayTrackingData } from '@/types'; export default function DailyReportPage() { const [report, setReport] = useState(null); @@ -74,6 +84,8 @@ export default function DailyReportPage() { // 生成轮询代际:新一轮生成或组件卸载时自增,使旧的 5s 轮询循环 // 在下一次醒来后直接退出,避免卸载后持续请求 / 交错 setState const generateSeqRef = useRef(0); + // 昨日追踪数据(报头兑现行 + ▲↑↓ 变化标记共用) + const [yesterdayData, setYesterdayData] = useState(null); useEffect(() => () => { generateSeqRef.current += 1; @@ -110,6 +122,21 @@ export default function DailyReportPage() { const [sparklines, setSparklines] = useState>({}); const [pickMarks, setPickMarks] = useState>({}); + // 昨日追踪:报头兑现行与 ▲↑↓ 的数据源(与 YesterdayTracking 组件同一端点, + // 页面持有一份供头版消费;错开主报告并发) + const fetchYesterday = useCallback((reportDate: string) => { + const fetcher = + reportScope === 'mine' + ? dailyReportApi.getMyYesterdayTracking + : dailyReportApi.getYesterdayTracking; + const timer = setTimeout(() => { + fetcher(reportDate) + .then(setYesterdayData) + .catch(() => setYesterdayData(null)); + }, 800); + return () => clearTimeout(timer); + }, [reportScope]); + const fetchReport = useCallback(async (date?: string) => { try { setLoading(true); @@ -119,6 +146,8 @@ export default function DailyReportPage() { : ((reportScope === 'mine' ? await dailyReportApi.getMyToday() : await dailyReportApi.getToday()) as unknown as DailyReportData); setReport(data); setSelectedDate(data.report_date); + setYesterdayData(null); + fetchYesterday(data.report_date); // 加载用户已标记的选题(恢复操作状态,刷新不丢失) // 标记键优先用 source_title(稳定);历史日报无该字段时回退 title。 @@ -165,18 +194,19 @@ export default function DailyReportPage() { } finally { setLoading(false); } - }, [reportScope]); + }, [reportScope, fetchYesterday]); useEffect(() => { - fetchReport(); + // 支持 /daily?date=YYYY-MM-DD 直达历史期(往期深链);无参默认今天 + const params = new URLSearchParams(window.location.search); + const dateParam = params.get('date'); + if (dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) && dateParam <= localDateString()) { + fetchReport(dateParam); + } else { + fetchReport(); + } }, [fetchReport]); - // T2: 当 scope 切换时刷新主报告 - useEffect(() => { - fetchReport(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportScope]); - const handleDateSelect = useCallback((date: string) => { if (date === selectedDate) return; fetchReport(date); @@ -223,7 +253,7 @@ export default function DailyReportPage() { } // 超时 if (seq === generateSeqRef.current) { - setError('生成超时,请稍后刷新查看'); + setError('生成超时,请稍候刷新查看'); } } catch (err: unknown) { if (seq === generateSeqRef.current) { @@ -287,16 +317,6 @@ export default function DailyReportPage() { [report, pickMarks], ); - const handleCalendarDayClick = (day: CalendarDay) => { - setSelectedDate(day.report_date); - if (day.has_report) { - fetchReport(day.report_date); - return; - } - setReport(null); - setError(`${day.report_date} 暂无日报,可手动补生成`); - }; - const keywords = parseJson(report?.keywords); const trends = parseJson(report?.trends); const topPicks = parseJson(report?.top_picks); @@ -327,6 +347,7 @@ export default function DailyReportPage() { const [markError, setMarkError] = useState(null); // 站内阅读:点选题卡片标题旁的 BookOpen 打开全局 ReaderDrawer(挂在 ClientLayout,全站单实例) const { openReader, currentUser } = useAppContext(); + const writePick = useWritePick(); // 手动推送日报到群 const [pushing, setPushing] = useState(false); const [pushMsg, setPushMsg] = useState(null); @@ -359,16 +380,51 @@ export default function DailyReportPage() { report?.status === 'DONE' ? report.id : undefined, ); - const generatedDates = useMemo(() => dates.filter((d) => d.report_date !== todayStr), [dates, todayStr]); const recoveryDays = calendarDays.filter((day) => day.status === 'MISSING' || day.status === 'ERROR'); - // 按 tier 分区,区内再按 category 分组。feature 优先展示(深度精讲),brief 次之(速览)。 - // 兼容历史数据:无 tier 字段视为 feature。 - const featureGroups = useMemo(() => groupByCategory(pickList, 'feature'), [pickList]); + // ── 晨报头版派生数据 ──────────────────────────────────────────────── + // 期号 = 已出刊日报天数(listDates 含今日) + const issueNumber = useMemo( + () => (datesLoading ? undefined : dates.length || undefined), + [dates, datesLoading], + ); + + // 昨日兑现行 + ▲↑↓ 标记(同一份 yesterday 数据) + const yesterdaySummary = useMemo(() => { + if (!yesterdayData?.has_yesterday || yesterdayData.picks.length === 0) return null; + const s = { confirmed: 0, reversed: 0, persisted: 0, dropped: 0 }; + for (const p of yesterdayData.picks) s[p.status] += 1; + return { ...s, recommended: yesterdayData.picks.length }; + }, [yesterdayData]); + + const yesterdayLine = yesterdaySummary + ? `昨日推荐 ${yesterdaySummary.recommended} 题 · 兑现 ${yesterdaySummary.confirmed} · 仍在榜 ${yesterdaySummary.persisted}` + : undefined; + + const changeMarkers = useMemo( + () => computeChangeMarkers(pickList, yesterdayData?.picks), + [pickList, yesterdayData], + ); + + // 头版:头题 = feature 档分数最高的一条;二条 = 其余 feature(跨分类混排) + const { leadPick, secondPicks } = useMemo(() => { + const features = pickList + .filter((p) => !p.tier || p.tier === 'feature') + .slice() + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return { leadPick: features[0], secondPicks: features.slice(1) }; + }, [pickList]); + const briefGroups = useMemo(() => groupByCategory(pickList, 'brief'), [pickList]); const hasBrief = briefGroups.length > 0; const readMinutes = Math.max(1, Math.ceil(pickList.length * 0.8)); + // 往期条:最近 5 期(不含当前期),复活 dates 数据 + const recentDates = useMemo(() => { + const others = dates.filter((d) => d.report_date !== report?.report_date); + return others.slice(-5).reverse(); + }, [dates, report?.report_date]); + return (
{/* 工具栏:scope + 日期选择 */} @@ -412,7 +468,7 @@ export default function DailyReportPage() {
- {/* 简洁日期选择器(替代 30 天日历) */} + {/* 简洁日期选择器 */}
- {/* 主区域 */} + {/* 主区域(晨报版式:报头 → 导语 → 头版 → 内页分版) */}
{loading ? ( @@ -528,185 +584,124 @@ export default function DailyReportPage() { ) : report?.status === 'GENERATING' ? ( 日报生成中,请稍候... ) : report && pickList.length > 0 ? ( -
- {/* 主编判断:先给出日报主线,再进入分层选题。 */} -
-
-
-
- {report.report_date} {report.weekday} - {/* 版本标签(复活死代码 EDITION_LABELS,区分午间快照/完整复盘) */} - {report.edition && EDITION_LABELS[report.edition] && ( - - {EDITION_LABELS[report.edition]} - - )} - {generatedAt && · 更新于 {generatedAt}} - · {pickList.length} 个选题 · 约 {readMinutes} 分钟 -
-

- {report.takeaway || report.overview?.slice(0, 80) || '今日内容已完成分析。'} -

- {report.overview && ( -
- 主编判断 - {report.overview} -
- )} -
-
+
+ {/* 报头(刊名/日期行/统计行:期号·昨日兑现·长度合同) */} +
+
- {/* 昨日追踪卡(一期补连续性闭环):昨日 top picks 的 24h 热度 delta + lifecycle 验证 */} - - - {/* 今日看点 TOC(可点击跳转,仅深度精讲) */} - {featureGroups.length > 1 && ( -
-
今日看点
-
- {featureGroups.map(([cat, picks]) => ( - - {picks.length} - {cat} - - ))} -
-
+ {/* 导语块(takeaway 衬线导语 + 主编判断) */} + + + {/* 头版:头题全展 + 二条窄栏(跨分类混排,带 ▲↑↓ 与还热) */} + {leadPick && ( + setShowOriginalLang(!showOriginalLang)} + onMark={handleMark} + onOpenReader={openReader} + onWrite={writePick} + /> )} - - {/* 深度精讲(feature:全字段卡片) */} - {featureGroups.length > 0 && ( -
- {featureGroups.map(([cat, picks], groupIdx) => ( -
- {/* 分组标题 */} -
- - {String(groupIdx + 1).padStart(2, '0')} - -

{cat}

- {CATEGORY_EN[cat] && ( - {CATEGORY_EN[cat]} - )} - {picks.length} 篇 -
- - {/* 组内选题列表 */} -
- {picks.map((pick, j) => { - const globalIdx = pickList.indexOf(pick); - const isExpanded = expandedPick === globalIdx; - const key = pickKey(pick); - return ( - setExpandedPick(isExpanded ? null : globalIdx)} - sparklineData={sparklines[key]} - markAction={pickMarks[key]} - showOriginalLang={showOriginalLang} - onToggleLang={() => setShowOriginalLang(!showOriginalLang)} - onMark={handleMark} - onOpenReader={openReader} - /> - ); - })} -
-
- ))} + {secondPicks.map((pick, j) => { + const globalIdx = pickList.indexOf(pick); + const isExpanded = expandedPick === globalIdx; + const key = pickKey(pick); + return ( + setExpandedPick(isExpanded ? null : globalIdx)} + markAction={pickMarks[key]} + onMark={handleMark} + onOpenReader={openReader} + onWrite={writePick} + /> + ); + })} + + {/* 中缝:昨日对账摘要 + 导流 */} + + + {/* 昨日追踪明细(折叠态:R8 校准后的问责剂量——一行摘要 + 明细可展开) */} +
+ + 昨日追踪明细(24h 热度与兑现) + +
+
- )} +
+ + {/* 内页分版:速览(brief 档按分类分版) */} {hasBrief && ( -
-
+
+
→ -

速览

- +

速览 · 分版

+ {briefGroups.reduce((n, [, ps]) => n + ps.length, 0)} 篇
-
- {briefGroups.map(([cat, picks]) => ( - picks.map((pick, j) => { - const globalIdx = pickList.indexOf(pick); - const isExpanded = expandedPick === globalIdx; - const key = pickKey(pick); - return ( - setExpandedPick(isExpanded ? null : globalIdx)} - markAction={pickMarks[key]} - showOriginalLang={showOriginalLang} - onToggleLang={() => setShowOriginalLang(!showOriginalLang)} - onMark={handleMark} - onOpenReader={openReader} - /> - ); - }) - ))} -
+ {briefGroups.map(([cat, picks], groupIdx) => ( +
+
+ + {String(groupIdx + 1).padStart(2, '0')} + + {cat} + {CATEGORY_EN[cat] && ( + {CATEGORY_EN[cat]} + )} + {picks.length} 篇 +
+
+ {picks.map((pick, j) => { + const globalIdx = pickList.indexOf(pick); + const isExpanded = expandedPick === globalIdx; + const key = pickKey(pick); + return ( + setExpandedPick(isExpanded ? null : globalIdx)} + markAction={pickMarks[key]} + showOriginalLang={showOriginalLang} + onToggleLang={() => setShowOriginalLang(!showOriginalLang)} + onMark={handleMark} + onOpenReader={openReader} + /> + ); + })} +
+
+ ))}
)} - {/* 背景层:趋势 + 关键词 + 平台建议 */} -
- {/* 趋势 */} - {trendList.length > 0 && ( - - -
- {trendList.map((trend, i) => ( -
- - {trend.momentum === 'up' ? '↑' : trend.momentum === 'down' ? '↓' : '→'} - -
- {trend.title} - {trend.desc} -
-
- ))} -
-
- )} - - {/* 关键词 */} - {keywordList.length > 0 && ( - - -
- {keywordList.map((kw, i) => ( - - {kw} - - ))} -
-
- )} -
+ {/* 趋势一行(增刊归还:缩为一行跳 /trends) */} + {/* 平台建议(折叠态) */} {platformTipEntries.length > 0 && ( -
+
平台创作建议 @@ -725,12 +720,38 @@ export default function DailyReportPage() {
)} - {/* 底部统计 */} -
+ {/* 往期条:最近 5 期(每天一格带当日导语) */} + {recentDates.length > 0 && ( +
+
往期 · 每天一格
+
+ {recentDates.map((d) => ( + + ))} +
+
+ )} + + {/* 底部统计 + 报脚 */} +
分析 {report.analyzed_count || 0} 条 推荐 {pickList.length} 个选题 {generatedAt && {generatedAt}}
+
+ 版面编辑:TopicEye 编辑台 · 热点不认行业,角度认赛道 · 每条可溯原文{issueNumber ? ` · 总第 ${issueNumber} 期` : ''} +
) : report ? ( diff --git a/frontend/src/app/monthly/page.tsx b/frontend/src/app/monthly/page.tsx index 2cc03e83..5ff593e1 100644 --- a/frontend/src/app/monthly/page.tsx +++ b/frontend/src/app/monthly/page.tsx @@ -34,6 +34,8 @@ export default function MonthlyDigestPage() { actionTitle="下月创作行动清单" overviewTitle="本月概述" keywordTitle="本月关键词" + clusterTitle="本月话题聚类" + categoryTitle="各版温度" historyIcon={CalendarDays} api={{ getCurrent: monthlyDigestApi.getCurrent, diff --git a/frontend/src/app/weekly/page.tsx b/frontend/src/app/weekly/page.tsx index ef811f37..04586453 100644 --- a/frontend/src/app/weekly/page.tsx +++ b/frontend/src/app/weekly/page.tsx @@ -36,6 +36,8 @@ export default function WeeklyDigestPage() { actionTitle="下周创作行动清单" overviewTitle="本周概述" keywordTitle="本周关键词" + clusterTitle="本周脉络" + clusterFirst historyIcon={ClipboardList} api={{ getCurrent: weeklyDigestApi.getCurrent, diff --git a/frontend/src/components/DigestReportPage.tsx b/frontend/src/components/DigestReportPage.tsx index a8d3a30a..21088696 100644 --- a/frontend/src/components/DigestReportPage.tsx +++ b/frontend/src/components/DigestReportPage.tsx @@ -116,6 +116,12 @@ interface DigestReportPageProps Promise; @@ -210,6 +216,9 @@ export default function DigestReportPage { try { setPeriodsLoading(true); @@ -300,6 +310,38 @@ export default function DigestReportPage>(digest?.category_summary, {}); const platformTips = parseJson>(digest?.platform_tips, {}); const topicClusters = parseJson(digest?.topic_clusters, []); + + // R8 周刊化:聚类块(clusterFirst=true 时前置到导语后,周报=本周脉络) + const clustersSection = topicClusters.length > 0 ? ( + + + +
+ {topicClusters.map((cluster, index) => ( +
+
+ {cluster.name} + + {cluster.count}篇 + +
+
代表: {cluster.representative_title}
+
+
+
+
+ {cluster.heat} +
+
+ ))} +
+ + + ) : null; + const actionItems = parseJson(digest?.action_items, []); const visiblePeriods = useMemo(() => periods, [periods]); const currentKey = digest ? getDigestKey(digest) : undefined; @@ -432,6 +474,9 @@ export default function DigestReportPage )} + {/* R8 周刊化:聚类节前置(周报=本周脉络,紧跟导语) */} + {clusterFirst && clustersSection} + {keywords.length > 0 && ( @@ -482,33 +527,7 @@ export default function DigestReportPage )} - {topicClusters.length > 0 && ( - - -
- {topicClusters.map((cluster, index) => ( -
-
- {cluster.name} - - {cluster.count}篇 - -
-
代表: {cluster.representative_title}
-
-
-
-
- {cluster.heat} -
-
- ))} -
- - )} + {!clusterFirst && clustersSection} {topPicks.length > 0 && ( @@ -552,7 +571,7 @@ export default function DigestReportPage 0 && ( - +
{Object.entries(categorySummary).map(([category, info]) => (