diff --git a/cmd/server/main.go b/cmd/server/main.go index 298b546..8e2db2e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,9 +4,12 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "log" "net/http" "os" + "path/filepath" + "strings" "time" _ "github.com/lib/pq" @@ -24,7 +27,7 @@ type modelResponse struct { Currency string `json:"currency"` IsFree bool `json:"isFree"` Stale bool `json:"stale"` - DataConfidence string `json:"dataConfidence"` + DataConfidence string `json:"dataConfidence"` } type subscriptionPlanResponse struct { @@ -54,6 +57,21 @@ type apiEnvelope struct { type modelFetcher func(context.Context, *sql.DB) ([]modelResponse, error) type subscriptionPlanFetcher func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) +type latestReportFetcher func(context.Context, *sql.DB) (*latestReportResponse, error) + +type latestReportResponse struct { + ReportDate string `json:"reportDate"` + Status string `json:"status"` + ModelCount int `json:"modelCount"` + SummaryMD string `json:"summaryMD"` + MarkdownPath string `json:"markdownPath"` + HTMLPath string `json:"htmlPath"` + ArchiveMarkdownPath string `json:"archiveMarkdownPath"` + ArchiveHTMLPath string `json:"archiveHtmlPath"` + MarkdownURL string `json:"markdownUrl"` + HTMLURL string `json:"htmlUrl"` + UpdatedAt string `json:"updatedAt"` +} func main() { addr := os.Getenv("PORT") @@ -75,7 +93,7 @@ func main() { } } - mux := newMux(db, fetchModels, fetchSubscriptionPlans) + mux := newMux(db, fetchModels, fetchSubscriptionPlans, fetchLatestReport) log.Printf("server listening on :%s", addr) if err := http.ListenAndServe(":"+addr, mux); err != nil { @@ -83,7 +101,7 @@ func main() { } } -func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher) *http.ServeMux { +func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher, fetchLatestReportFn latestReportFetcher) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { if db == nil { @@ -122,6 +140,29 @@ func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPla } writeJSON(w, http.StatusOK, apiEnvelope{Data: plans}) }) + mux.HandleFunc("/api/v1/reports/latest/html", func(w http.ResponseWriter, r *http.Request) { + serveLatestReportArtifact(w, r, db, fetchLatestReportFn, "html") + }) + mux.HandleFunc("/api/v1/reports/latest/markdown", func(w http.ResponseWriter, r *http.Request) { + serveLatestReportArtifact(w, r, db, fetchLatestReportFn, "markdown") + }) + mux.HandleFunc("/api/v1/reports/latest", func(w http.ResponseWriter, r *http.Request) { + if db == nil { + http.Error(w, "database not configured", http.StatusServiceUnavailable) + return + } + report, err := fetchLatestReportFn(r.Context(), db) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "latest report not found", http.StatusNotFound) + return + } + http.Error(w, "query failed", http.StatusInternalServerError) + log.Printf("fetch latest report failed: %v", err) + return + } + writeJSON(w, http.StatusOK, apiEnvelope{Data: report}) + }) return mux } @@ -129,15 +170,16 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) { rows, err := db.QueryContext(ctx, ` WITH latest_prices AS ( SELECT - model_id, - input_price_per_mtok, - output_price_per_mtok, - currency, + rp.model_id, + rp.input_price_per_mtok, + rp.output_price_per_mtok, + rp.currency, + rp.is_free, ROW_NUMBER() OVER ( - PARTITION BY model_id - ORDER BY effective_date DESC NULLS LAST, id DESC + PARTITION BY rp.model_id + ORDER BY rp.effective_date DESC NULLS LAST, rp.id DESC ) AS rn - FROM model_prices + FROM region_pricing rp ) SELECT m.external_id, @@ -149,7 +191,7 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) { lp.input_price_per_mtok, lp.output_price_per_mtok, COALESCE(lp.currency, 'USD'), - COALESCE(m.is_free, false), + COALESCE(lp.is_free, m.is_free, false), COALESCE(m.data_confidence, 'official') FROM models m LEFT JOIN model_provider mp ON mp.id = m.provider_id @@ -196,6 +238,98 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) { return models, rows.Err() } +func fetchLatestReport(ctx context.Context, db *sql.DB) (*latestReportResponse, error) { + var report latestReportResponse + var markdownPath string + + err := db.QueryRowContext(ctx, ` + SELECT + TO_CHAR(report_date, 'YYYY-MM-DD'), + status, + COALESCE(model_count, 0), + COALESCE(summary_md, ''), + COALESCE(output_path, ''), + COALESCE(TO_CHAR(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS'), '') + FROM daily_report + WHERE output_path IS NOT NULL + AND output_path <> '' + ORDER BY report_date DESC, updated_at DESC + LIMIT 1 + `).Scan( + &report.ReportDate, + &report.Status, + &report.ModelCount, + &report.SummaryMD, + &markdownPath, + &report.UpdatedAt, + ) + if err != nil { + return nil, err + } + + report.MarkdownPath = filepath.ToSlash(markdownPath) + report.HTMLPath = deriveReportHTMLPath(markdownPath, report.ReportDate) + report.ArchiveMarkdownPath = deriveReportArchivePath(markdownPath, report.ReportDate) + report.ArchiveHTMLPath = deriveReportArchivePath(report.HTMLPath, report.ReportDate) + report.MarkdownURL = "/api/v1/reports/latest/markdown" + report.HTMLURL = "/api/v1/reports/latest/html" + return &report, nil +} + +func serveLatestReportArtifact(w http.ResponseWriter, r *http.Request, db *sql.DB, fetchLatestReportFn latestReportFetcher, artifactType string) { + if db == nil { + http.Error(w, "database not configured", http.StatusServiceUnavailable) + return + } + + report, err := fetchLatestReportFn(r.Context(), db) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "latest report not found", http.StatusNotFound) + return + } + http.Error(w, "query failed", http.StatusInternalServerError) + log.Printf("fetch latest report failed: %v", err) + return + } + + targetPath := report.MarkdownPath + if artifactType == "html" { + targetPath = report.HTMLPath + w.Header().Set("Content-Type", "text/html; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + } + + if _, err := os.Stat(targetPath); err != nil { + http.Error(w, "report artifact not found", http.StatusNotFound) + return + } + + http.ServeFile(w, r, targetPath) +} + +func deriveReportHTMLPath(markdownPath, reportDate string) string { + reportFile := filepath.Base(markdownPath) + if reportFile == "." || reportFile == "" { + reportFile = fmt.Sprintf("daily_report_%s.md", reportDate) + } + htmlFile := strings.TrimSuffix(reportFile, filepath.Ext(reportFile)) + ".html" + reportDir := filepath.Dir(markdownPath) + if reportDir == "." || reportDir == "" { + reportDir = "reports/daily" + } + return filepath.ToSlash(filepath.Join(reportDir, "html", htmlFile)) +} + +func deriveReportArchivePath(reportPath, reportDate string) string { + reportFile := filepath.Base(reportPath) + if reportFile == "." || reportFile == "" { + reportFile = fmt.Sprintf("daily_report_%s.md", reportDate) + } + return filepath.ToSlash(filepath.Join("reports/daily", reportDate[:4], reportDate[5:7], reportFile)) +} + func fetchSubscriptionPlans(ctx context.Context, db *sql.DB) ([]subscriptionPlanResponse, error) { rows, err := db.QueryContext(ctx, ` SELECT diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index dba91bb..49d30fb 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" ) @@ -38,6 +39,9 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { }, }, nil }, + func(context.Context, *sql.DB) (*latestReportResponse, error) { + return nil, sql.ErrNoRows + }, ) req := httptest.NewRequest(http.MethodGet, "/api/v1/subscription-plans", nil) @@ -76,3 +80,85 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { t.Fatalf("unexpected model scope length: %d", len(got.ModelScope)) } } + +func TestLatestReportHandlerReturnsEnvelope(t *testing.T) { + mux := newMux( + &sql.DB{}, + func(context.Context, *sql.DB) ([]modelResponse, error) { + return nil, nil + }, + func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) { + return nil, nil + }, + func(context.Context, *sql.DB) (*latestReportResponse, error) { + return &latestReportResponse{ + ReportDate: "2026-05-13", + Status: "generated", + ModelCount: 504, + MarkdownPath: "reports/daily/daily_report_2026-05-13.md", + HTMLPath: "reports/daily/html/daily_report_2026-05-13.html", + MarkdownURL: "/api/v1/reports/latest/markdown", + HTMLURL: "/api/v1/reports/latest/html", + }, nil + }, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/latest", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + var payload struct { + Data latestReportResponse `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if payload.Data.ReportDate != "2026-05-13" { + t.Fatalf("unexpected report date: %q", payload.Data.ReportDate) + } + if payload.Data.HTMLURL != "/api/v1/reports/latest/html" { + t.Fatalf("unexpected html url: %q", payload.Data.HTMLURL) + } +} + +func TestLatestReportHTMLHandlerServesArtifact(t *testing.T) { + tempDir := t.TempDir() + htmlPath := tempDir + "/daily_report_2026-05-13.html" + if err := os.WriteFile(htmlPath, []byte("
ok"), 0644); err != nil { + t.Fatalf("write temp html: %v", err) + } + + mux := newMux( + &sql.DB{}, + func(context.Context, *sql.DB) ([]modelResponse, error) { + return nil, nil + }, + func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) { + return nil, nil + }, + func(context.Context, *sql.DB) (*latestReportResponse, error) { + return &latestReportResponse{ + ReportDate: "2026-05-13", + Status: "generated", + MarkdownPath: tempDir + "/daily_report_2026-05-13.md", + HTMLPath: htmlPath, + }, nil + }, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/latest/html", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if body := rec.Body.String(); body != "ok" { + t.Fatalf("unexpected body: %q", body) + } +} diff --git a/docs/plans/2026-05-13-daily-report-ui-design.md b/docs/plans/2026-05-13-daily-report-ui-design.md new file mode 100644 index 0000000..f112ff8 --- /dev/null +++ b/docs/plans/2026-05-13-daily-report-ui-design.md @@ -0,0 +1,992 @@ +# 日报重构 UI 设计说明书 + +> 项目:LLM Intelligence Hub +> 日期:2026-05-13 +> 状态:设计基线 +> 适用范围:HTML 日报、前端 Dashboard 日报摘要入口、后续移动端 / Web 端统一视觉规范 + +## 1. 背景 + +当前日报已经能输出真实数据,但整体阅读体验更接近“数据库导出结果页”,而不是“每天值得打开的 AI 模型与价格情报产品”。 + +现状问题: + +- 首屏缺少“今日发生了什么”的判断,读者看完仍不知道重点。 +- 免费模型、推荐模型、分类概览之间存在重复,信息层级不清。 +- “免费”语义混淆,官方免费、聚合免费、活动免费没有视觉区分。 +- 移动端沿用了桌面表格逻辑,不适合快速浏览。 +- 页面缺少品牌气质,无法体现“高端移动资讯产品 + 专业选型工具”的特点。 + +本次 UI 重构不推翻底层数据库,不改动项目对模型、价格、平台、来源的原始采集事实;只在日报内容组织和页面呈现层重构“让用户愿意看、看得懂、看完能行动”的产品体验。 + +## 2. 设计目标 + +### 2.1 产品目标 + +新日报要同时服务两类用户: + +- 选型决策者:今天该试什么模型,哪个更划算,哪些来源更稳。 +- 行业情报读者:今天市场发生了什么变化,哪些发布、活动、价格战值得关注。 + +默认优先级:**两者兼顾,但优先选型**。 + +### 2.2 体验目标 + +新日报必须满足以下体验要求: + +- 用户在 30 秒内理解“今天最重要的变化”。 +- 用户在 1 分钟内得到“今天该优先关注谁”的结论。 +- 用户能一眼分辨“官方免费 / 聚合免费 / 活动免费 / 来源待验证”。 +- 移动端首屏不依赖长表格,也不依赖大段说明文字。 +- 页面视觉上要像“高端移动资讯产品”,而不是管理后台。 + +### 2.3 不做什么 + +- 不把首页继续做成全量模型清单。 +- 不在主阅读区堆长段分析文字。 +- 不把灰区或待验证来源直接混入主推荐。 +- 不为了炫技加入噪音型动画或低质装饰。 + +## 3. 内容策略 + +### 3.1 基本原则 + +日报采用以下原则: + +- 先结论,后证据。 +- 先变化,后存量。 +- 先行动建议,后完整数据。 +- 主区讲重点,附录讲完整。 + +### 3.2 主体结构 + +日报正文固定拆成四层: + +1. 今日结论区 +2. 今日变化区 +3. 今日选型推荐区 +4. 附录区 + +其中: + +- `今日结论区` 负责抓眼球和快速定调。 +- `今日变化区` 负责解释“为什么今天值得看”。 +- `今日选型推荐区` 负责直接输出可执行建议。 +- `附录区` 负责保留数据库完整性与查阅深度。 + +### 3.3 事件类型 + +日报重点事件按以下类型组织: + +- `new_model` +- `official_release` +- `price_cut` +- `price_increase` +- `free_policy_change` +- `promo_campaign` +- `source_risk_change` + +同一模型可以有多个事件标签,但在首页只选择一个主标签展示。 + +### 3.3.1 变化基线规则 + +为保证“今日变化”具备稳定解释力,所有变化事件必须绑定统一的比较基线,禁止只展示“变了”而不说明“相对什么变了”。 + +允许使用的变化基线: + +- `较昨日` +- `较上次有效价格` +- `7日内新低 / 新高` +- `首次出现` +- `官方首次发布` + +规则要求: + +- 所有 `price_cut` / `price_increase` 必须显示比较基线。 +- 所有 `new_model` 必须显示“首次出现”或“官方首次发布”。 +- 所有 `free_policy_change` 必须显示“由付费转免费”或“由免费转付费”的方向信息。 +- UI 上必须保留一个稳定的基线展示位,不允许只靠颜色表达变化。 + +推荐展示格式: + +- `较昨日 -18%` +- `较上次调价下降 ¥0.20/M` +- `7日内最低价` +- `首次出现在可信来源` + +### 3.4 免费模型与来源可信度 + +日报层必须在原始数据上额外生成语义标签: + +- `official_free` +- `aggregator_free` +- `promo_free` +- `trial_credit` +- `unknown_free` + +来源可信度分级: + +- `official_verified` +- `cloud_verified` +- `aggregator_verified` +- `self_hosted_gateway` +- `unverified_relay` + +规则要求: + +- 所有“免费”展示必须带来源类型徽标。 +- 主推荐区默认只允许 `official_verified`、`cloud_verified`、`aggregator_verified`。 +- `self_hosted_gateway` 与 `unverified_relay` 只进入观察区或风险提醒区,不进入主推荐。 + +### 3.4.1 来源证据露出规则 + +来源可信度标签不能只做视觉标识,必须给用户可追溯的证据入口。 + +对以下卡片强制增加来源证据位: + +- 头条事件卡 +- 推荐模型卡 +- 免费模型卡 +- 风险提示卡 + +每张卡片至少要能露出: + +- 主来源名称 +- 更新时间 +- 来源链接入口 + +可选露出: + +- 判定说明 +- 采集时间 +- 次级证据来源 + +交互建议: + +- 移动端默认展示“来源名称 + 更新时间”,点击展开二级抽屉或详情层查看完整来源信息。 +- Web 端可使用 tooltip、侧边详情层或内联折叠区展示判定说明。 + +目标: + +- 用户看到“官方来源”“聚合来源”“来源待验证”时,能够进一步确认其依据,而不是只看到一个不可解释的标签。 + +## 4. 视觉方向 + +### 4.1 总体方向 + +本项目 UI 气质定义为: + +**高端移动资讯产品 + 专业决策工具** + +不是传统 BI Dashboard,也不是普通资讯流,而是“AI 情报晨报 + 交易决策界面”的混合体。 + +### 4.2 风格关键词 + +- 高端资讯感 +- 科技商业感 +- 情报头条感 +- 专业但不冰冷 +- 可快速扫读 +- 强标签系统 + +### 4.3 视觉原则 + +- 浅底,不以深色为默认主题。 +- 重点靠信息层级抓眼球,不靠大图堆叠。 +- 大字号结论、大标签、少字说明。 +- 颜色必须承担信息语义,而不是纯装饰。 +- 卡片感明确,但避免“廉价卡片流”。 + +## 5. 视觉系统规范 + +### 5.1 颜色系统 + +主色建议: + +- 墨蓝:核心情报、正式发布、品牌主色 +- 祖母绿:降价、利好、值得试 +- 琥珀橙:活动、促销、观察项 +- 朱砂红:来源风险、灰区提醒、负面变化 +- 雾灰:背景、辅助信息、弱层级 + +颜色语义要求: + +- 用户看到颜色即可大致判断事件性质。 +- 同一类标签在所有模块中颜色必须一致。 + +### 5.2 字体系统 + +文字不能小,移动端优先规则如下: + +- 一句话结论:22px-26px +- 头条标题:18px-20px +- 卡片标题:18px +- 正文短句:15px-16px +- 标签:12px-13px +- 主阅读区正文禁止低于 14px + +设计要求: + +- 标题字要有媒体感和辨识度。 +- 正文字必须优先可读性。 +- 不使用普通后台式默认字体堆满页面。 + +### 5.3 标签系统 + +标签是核心视觉语言之一,必须标准化。 + +建议标签: + +- 官方发布 +- 聚合免费 +- 官方免费 +- 限时活动 +- 价格下调 +- 来源待验证 +- 适合编码 +- 适合 Agent +- 官方来源 +- 聚合来源 + +约束: + +- 每张卡最多展示 3 个标签。 +- 标签必须足够粗、足够醒目,但不应压过主标题。 + +### 5.4 图标系统 + +采用简洁功能型图标,不做插画主导。 + +建议图标语义: + +- 火焰:热点 +- 向下箭头:降价 +- 礼盒 / 闪电:活动 +- 盾牌:可信来源 +- 感叹号:风险提醒 + +### 5.5 动效规范 + +动效应提升高级感,不制造噪音。 + +建议: + +- 首屏卡片分层渐入 +- 标签轻微浮现 +- 卡片 hover / tap 有短促反馈 +- 折叠区展开用轻量过渡 + +禁止: + +- 大范围抖动 +- 夸张发光 +- 低质漂浮动效 +- 干扰阅读的连续动画 + +## 6. 移动端首页结构 + +移动端首页按“先结论,后机会,再证据”组织,共六个区块。 + +### 6.1 顶部情报头 + +内容: + +- 日期 +- 数据更新时间 +- 今日市场状态标签 +- 一句短副标题 + +目标: + +- 让用户第一眼知道这是一份“今日 AI 情报晨报”。 + +### 6.2 一句话结论卡 + +规则: + +- 只保留 1 条主结论 +- 控制在 28-40 个中文字符 +- 最多 2-3 行 +- 必须在手机首屏内出现 + +作用: + +- 让用户一眼知道“今天真正重要的变化是什么”。 + +### 6.3 三条行动建议 + +每条卡片固定结构: + +- 建议动作 +- 适用人群 +- 2-3 个原因标签 + +目标: + +- 用户无需先读完整日报,就能获得可执行建议。 + +### 6.4 今日头条卡片流 + +内容: + +- 新模型发布 +- 重要价格变化 +- 活动 / 免费策略变化 + +卡片必须包含: + +- 事件标签 +- 标题 +- 为什么重要 +- 来源可信度 + +### 6.5 场景推荐区 + +按场景分组: + +- 低成本编码 +- 中文通用 +- Agent / 工具调用 +- 视觉 / 多模态 + +每组最多展示 3 个候选。 + +### 6.6 附录入口 + +附录包含: + +- 完整免费模型 +- 完整价格表 +- 平台覆盖 +- 套餐信息 + +要求: + +- 默认收起 +- 不打断首页信息节奏 + +## 7. Web 端布局策略 + +Web 端不是移动端的放大版,而是“带更多证据的完整版”。 + +### 7.1 首页布局 + +建议: + +- 左侧:一句话结论 + 行动建议 + 今日头条 +- 右侧:关键指标 + 风险提示 + 今日市场状态 + +### 7.2 内容承载 + +- 场景推荐区适合用矩阵布局增强对比。 +- 附录区允许展开更多表格和来源说明。 +- 可加入锚点导航,支持快速跳到: + - 今日变化 + - 推荐 + - 免费来源 + - 附录 + +### 7.3 一致性 + +移动端与 Web 端必须共享同一套: + +- 一句话结论 +- 头条事件 +- 推荐场景 +- 来源可信度标签 + +## 8. 核心组件定义 + +建议优先设计以下组件: + +### 8.1 一句话结论卡 + +用途: + +- 承担首屏最大视觉锚点 + +要求: + +- 大字号 +- 极少文字 +- 强背景对比 + +### 8.2 行动建议卡 + +用途: + +- 输出“今天该做什么” + +要求: + +- 标题明确 +- 适用对象明确 +- 理由用标签表达 +- 必须保留 1 个“证据短句位” + +### 8.3 头条事件卡 + +用途: + +- 承载新发布、降价、活动等高信号事件 + +要求: + +- 强标题 +- 强标签 +- 强数字 + +### 8.4 推荐模型卡 + +用途: + +- 承载场景化推荐 + +要求: + +- 先显示模型名与用途 +- 再显示来源与价格 +- 不再用长表格表达 +- 必须保留 1 个“关键证据短句位” +- 必须保留来源证据入口 + +### 8.5 来源可信度标签 + +用途: + +- 解决“免费是真的吗”“这个来源可不可信”的核心疑问 + +要求: + +- 视觉语义强 +- 全局复用 + +### 8.6 风险提示卡 + +用途: + +- 承载灰区来源、待验证来源、活动时效风险 + +要求: + +- 在色彩和语义上明显区别于机会卡 + +### 8.7 证据短句位规范 + +为避免页面只剩“推荐结论”而缺少决策依据,行动建议卡和推荐模型卡都必须保留一个固定的证据短句位。 + +证据短句位要求: + +- 只允许 1 行 +- 长度控制在 10-24 个中文字符 +- 优先展示“今天为什么值得关注”的理由 + +推荐文案模板: + +- `较昨日低 18%` +- `官方免费额度已确认` +- `首次发布,支持 256K` +- `聚合免费,适合尝鲜` +- `活动价,截止 05-31` + +设计原则: + +- 证据短句不是说明文,而是高信号决策依据。 +- 证据短句必须和推荐动作或头条判断形成闭环。 + +## 9. 抓眼球规则 + +首页必须显著抓眼,但不依赖低质量视觉噪音。 + +强制规则: + +- 首屏不能以大表格开头。 +- 首屏必须有一条大字号结论。 +- 文字说明尽量短,不允许连续长段。 +- 卡片正文以短句为主,避免三行以上解释。 +- 每屏只承担一个阅读任务。 + +判断标准: + +- 用户不需要阅读完整页,扫一眼也知道今天的重点。 +- 用户不会因为文字太密或太小而放弃继续阅读。 + +## 10. 交付物清单 + +正式设计交付建议包括: + +### 10.1 视觉方向稿 + +- 首页氛围图 +- 结论卡 / 行动建议卡 / 头条卡风格样张 +- 颜色与字体方向 + +### 10.2 信息架构稿 + +- 移动端首页草图 +- Web 首页草图 +- 模块优先级说明 + +### 10.3 核心组件稿 + +- 一句话结论卡 +- 行动建议卡 +- 头条卡 +- 推荐卡 +- 标签体系 +- 风险提示卡 + +### 10.4 高保真页面稿 + +至少三套: + +- 移动端首页 +- Web 端首页 +- 附录 / 展开态 + +必做扩展: + +- 平静日版本 +- 热点日版本 + +说明: + +- 平静日版本用于当天重大变化较少时,首页自动转向“观察重点 + 稳定推荐”结构。 +- 热点日版本用于新模型、降价、活动较多时,首页强化头条和事件卡密度。 +- 这两种状态都必须在设计阶段覆盖,避免实现后在“无事发生的日子”退化为旧式榜单页。 + +## 11. 页面级线框与高保真说明 + +本章作为 V1 实现前的页面级设计基线,直接约束移动端首页、Web 首页、以及“平静日 / 热点日”的状态切换方式。实现阶段不得绕开本章退回到“统计块 + 长表格”的旧结构。 + +### 11.1 移动端首页线框 + +目标设备: + +- 设计基准宽度:390px +- 安全区左右边距:16px +- 卡片圆角建议:20px +- 卡片间距:12px +- 可点击区域最小尺寸:44px x 44px + +信息顺序固定如下: + +1. 顶部情报头 +2. 一句话结论卡 +3. 三条行动建议 +4. 今日头条卡片流 +5. 场景推荐区 +6. 附录入口 + +线框结构: + +```text ++--------------------------------------------------+ +| 日期 / 更新时间 / 市场状态标签 | +| 一句短副标题 | ++--------------------------------------------------+ +| 今日一句话结论 | +| 1 行标签:价格战 / 官方发布 / 聚合免费偏多 | ++--------------------------------------------------+ +| 建议卡 1 | +| 建议卡 2 | +| 建议卡 3 | ++--------------------------------------------------+ +| 今日头条 | +| 头条卡 1 | +| 头条卡 2 | +| 头条卡 3 | ++--------------------------------------------------+ +| 场景推荐 | +| 低成本编码 | +| 中文通用 | +| Agent / 工具调用 | +| 视觉 / 多模态 | ++--------------------------------------------------+ +| 附录入口:完整价格 / 完整免费 / 平台覆盖 | ++--------------------------------------------------+ +``` + +滚动节奏要求: + +- 首屏 1 到 1.5 屏内,必须完整出现“顶部情报头 + 一句话结论卡 + 至少 1 张行动建议卡”。 +- 第二屏必须进入“今日头条”或“场景推荐”,不能被长段说明占满。 +- 附录入口必须在前三次滑动内可见,但默认不展开长表格。 + +### 11.2 移动端首页高保真说明 + +#### 11.2.1 顶部情报头 + +固定字段: + +- 左侧:`05-13 Wed` +- 右侧:`08:35 更新` +- 下方标签:`价格战活跃`、`新模型日`、`免费策略波动` +- 最底一行:一句短副标题,控制在 18 个中文字符内 + +视觉要求: + +- 顶部区域不使用纯白平板,采用轻雾灰底 + 细粒度渐变。 +- 标签采用胶囊形,单个标签宽度不超过一行的 40%。 +- 日期和更新时间用较小字号,但不得低于 14px。 + +#### 11.2.2 一句话结论卡 + +内容结构固定: + +- 主结论:1 条,28-40 个中文字符 +- 辅助标签:最多 2 个 +- 可选证据短句:1 条,10-18 个中文字符 + +视觉层级: + +- 主结论字号:22px-26px,字重明显高于正文 +- 卡片背景优先使用墨蓝浅化渐变或暖灰底叠加高亮描边 +- 该卡片必须成为首屏最大视觉锚点 + +禁止事项: + +- 禁止在该卡片中堆 2 段以上解释文字 +- 禁止放 4 个以上标签 +- 禁止把统计数字作为主标题替代结论句 + +#### 11.2.3 行动建议卡 + +每张卡固定包含 4 行: + +1. 动作标题,例如 `今天先试它` +2. 适用人群,例如 `适合低成本代码生成` +3. 标签组,最多 3 个 +4. 证据短句位,必须存在 + +卡片高度建议: + +- 默认高度:112px-128px +- 标题最多 1 行 +- 适用人群最多 1 行 +- 证据短句最多 1 行 + +视觉要求: + +- 三张卡必须形成强弱关系,推荐优先级最高的一张使用更高对比色边框或更厚阴影。 +- 不允许三张卡完全同权展示,否则用户无法一眼分辨首选动作。 + +#### 11.2.4 今日头条卡 + +每张头条卡固定包含: + +- 事件标签 +- 标题 +- 影响短句 +- 比较基线或关键数字 +- 来源可信度标签 + +内容长度限制: + +- 标题最多 2 行 +- 影响短句最多 2 行 +- 关键数字必须大于正文层级 + +视觉要求: + +- `新发布` 优先用墨蓝 +- `价格下调` 优先用祖母绿 +- `活动 / 促销` 优先用琥珀橙 +- `来源风险` 优先用朱砂红 + +#### 11.2.5 场景推荐区 + +每个场景模块固定包含: + +- 场景标题 +- 1 个主推荐卡 +- 2 个次推荐条目 +- 1 个“查看更多”入口 + +展示规则: + +- 主推荐卡允许露出模型名、用途、来源类型、价格摘要、证据短句 +- 次推荐条目只露出模型名 + 1 个标签 + 1 个价格摘要 +- 同一模型在一个首页中最多出现 2 次 + +#### 11.2.6 附录入口 + +移动端首页只允许展示附录入口,不允许直接铺开完整表格。 + +入口结构: + +- 标题:`完整数据附录` +- 三个快捷入口:`完整价格`、`完整免费`、`平台覆盖` +- 一条解释短句:`适合深度比价时查看` + +### 11.3 Web 首页线框 + +目标设备: + +- 设计基准宽度:1440px +- 主内容最大宽度:1280px +- 栅格:12 列 +- 页面左右留白:48px-64px + +布局固定如下: + +- 左 7 列:一句话结论卡、行动建议、今日头条、场景推荐 +- 右 5 列:关键指标、市场状态、风险提醒、来源说明入口 + +线框结构: + +```text ++-----------------------------+---------------------------+ +| 顶部情报头 | 今日市场状态 / 风险摘要 | ++-----------------------------+---------------------------+ +| 一句话结论卡 | 关键指标卡组 | ++-----------------------------+---------------------------+ +| 三条行动建议 | 来源可信度说明 | ++-----------------------------+---------------------------+ +| 今日头条卡组 | 风险提示卡 | ++-----------------------------+---------------------------+ +| 场景推荐矩阵 | 锚点导航 / 附录入口 | ++---------------------------------------------------------+ +| 附录区:完整价格 / 完整免费 / 平台覆盖 / 来源证据 | ++---------------------------------------------------------+ +``` + +Web 端目标不是“更花”,而是“更方便横向比较”。因此: + +- 左列承担叙事和推荐。 +- 右列承担解释和证据。 +- 附录区承担完整查询,不干扰上半屏结论阅读。 + +### 11.4 Web 首页高保真说明 + +#### 11.4.1 顶部信息带 + +顶部允许比移动端多展示 1 组指标,但仍遵循“短句优先”: + +- 日期 +- 更新时间 +- 今日变化摘要:新增模型数、降价数、活动数 +- 市场状态标签 + +要求: + +- 顶部信息带总高度控制在 88px-112px +- 不允许做成传统 KPI 仪表盘 + +#### 11.4.2 关键指标卡组 + +右侧指标卡组只保留 4 张: + +- 今日新增模型 +- 今日重要降价 +- 官方免费数量 +- 聚合免费数量 + +展示要求: + +- 数字大,解释短 +- 每张卡必须有“指标含义”短标签 +- 禁止在 Web 首屏出现 8 张以上统计块 + +#### 11.4.3 场景推荐矩阵 + +Web 端场景推荐允许做成 2 x 2 矩阵: + +- 左上:低成本编码 +- 右上:中文通用 +- 左下:Agent / 工具调用 +- 右下:视觉 / 多模态 + +每格固定结构: + +- 场景标题 +- 主推荐 1 条 +- 次推荐 2 条 +- 来源说明入口 + +#### 11.4.4 来源与风险区 + +右侧必须有一个常驻区块,用于解释: + +- 今日哪些“免费”是官方免费 +- 哪些免费来自聚合平台 +- 哪些来源待验证 +- 哪些活动存在截止时间 + +该区块默认用短句摘要表达,并允许展开查看证据。 + +### 11.5 视觉层级固定规则 + +为防止实现时退化成“信息都一样重要”,页面层级固定如下: + +- `P0`:一句话结论卡 +- `P1`:第一张行动建议卡 + 第一条头条 +- `P2`:其余行动建议卡 + 其余头条卡 +- `P3`:场景推荐主卡 +- `P4`:附录入口、解释性文字、完整表格 + +实现要求: + +- 首屏同一时刻只能有 1 个 P0。 +- 每个区块最多存在 1 个强主色焦点。 +- P4 信息不能在视觉上压过 P1 / P2。 + +### 11.6 状态切换规则 + +首页必须支持三种状态,并由数据自动驱动: + +#### 11.6.1 常规日 + +触发条件: + +- 有 1-2 条重要变化 +- 或存在 1 条较强头条但整体事件密度不高 + +页面策略: + +- 保持标准六区块结构 +- 头条区展示 2-3 张卡 +- 场景推荐正常展开 + +#### 11.6.2 平静日 + +触发条件建议: + +- 重大变化事件少于 2 条 +- 且无 `official_release` +- 且无显著降价或活动 + +页面策略: + +- 结论卡改为“观察重点 + 稳定推荐”语气 +- 行动建议卡优先展示“稳定商用选择” +- 头条区减少到 1-2 张,并允许用“今日无重大上新 / 无显著调价”作为信息性卡片 +- 场景推荐上移,承担更多首页价值 + +禁止事项: + +- 禁止用旧榜单、旧大表格填满头条区 +- 禁止为了“看起来有内容”重复同一模型三次以上 + +#### 11.6.3 热点日 + +触发条件建议: + +- `official_release` >= 1 +- 或重大变化事件 >= 3 +- 或同日存在“新发布 + 降价 + 活动”组合 + +页面策略: + +- 顶部情报头增加“热点日”状态标签 +- 今日头条区允许扩展到 4 张卡 +- 第一条头条卡可升级为宽版主头条 +- 场景推荐保留,但默认折叠次推荐条目,避免首屏过长 + +### 11.7 页面状态验收要求 + +线框和高保真说明必须同时覆盖以下状态: + +- 移动端常规日 +- 移动端平静日 +- 移动端热点日 +- Web 端常规日 +- Web 端平静日 +- Web 端热点日 + +验收标准: + +- 任一状态下,用户在 30 秒内都能说出“今天值不值得关注”。 +- 平静日不会退化成榜单堆砌页。 +- 热点日不会因为信息过多而丢失主结论。 + +## 12. 版本路线图 + +### V1 可读版 + +目标: + +- 让日报从“数据导出页”变成“人能快速看懂的日报” + +包含: + +- 一句话结论 +- 3 条行动建议 +- 今日变化摘要 +- 免费来源类型标签 +- 附录后置 + +验收: + +- 用户 30 秒内说出今天最重要变化 +- 免费区 100% 带来源类型标签 +- 首屏不再是大表格 +- 存在“平静日状态”首页方案,且不使用重复榜单填充头条区 + +### V2 情报版 + +目标: + +- 让日报具备“每天值得打开”的新闻价值 + +包含: + +- 事件流 +- 今日头条 3 条 +- 活动 / 发布 / 降价打标 +- 来源可信度分级 + +验收: + +- 80% 日报至少有 1 条真正变化事件 +- 头条区每条都含事件类型、可信度、影响对象 +- 主推荐区不出现待验证来源 + +### V3 专业版 + +目标: + +- 形成可持续的 AI 模型与价格情报产品 + +包含: + +- 事件表 / 来源注册表 +- 风险分层 +- 趋势入口 +- 周报 / 专题扩展能力 + +验收: + +- 用户可把日报当作日常选型输入 +- 变化、活动、来源风险都能稳定进入产品 + +## 13. 实施建议 + +推荐实施顺序: + +1. 先完成移动端首页高保真设计 +2. 再扩展 Web 端版式 +3. 抽出组件规范 +4. 改造 HTML 日报模板 +5. 改造 Dashboard 日报入口与摘要视图 +6. 用真实日报数据回填验收 + +推荐工程路径: + +- 优先改造 `scripts/generate_daily_report.go` 中的 HTML 模板和内容编排 +- 复用现有 `/api/v1/reports/latest` 能力,在前端摘要入口中承载新版视觉模块 +- 后续再将事件与来源标签能力沉淀到独立模块 + +## 14. 结论 + +本次 UI 重构的目标不是单纯“美化日报”,而是把日报变成一个: + +- 愿意每天打开的高端移动资讯产品 +- 能快速做出选型判断的专业工具 +- 不牺牲底层数据库完整性的情报展示层 + +后续实现必须始终围绕三条主线: + +- 信息层级清楚 +- 来源可信度透明 +- 读者能快速行动 diff --git a/docs/plans/2026-05-13-daily-report-v1-implementation-plan.md b/docs/plans/2026-05-13-daily-report-v1-implementation-plan.md new file mode 100644 index 0000000..6794c34 --- /dev/null +++ b/docs/plans/2026-05-13-daily-report-v1-implementation-plan.md @@ -0,0 +1,296 @@ +# Daily Report V1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将当前“数据库导出式日报”改造成移动端优先、变化驱动、可快速决策的 V1 日报页面,并同步补齐 Dashboard 摘要入口。 + +**Architecture:** 保留现有数据库和采集链路不动,把改造集中在 `scripts/generate_daily_report.go` 的报告语义层与 HTML 模板层。先把“结论、行动建议、头条、免费来源标签、场景推荐”抽成可测试的构建函数,再重写 HTML 模板和前端摘要视图,最后用真实日报生成、Go 测试、前端构建联合验收。 + +**Tech Stack:** Go 1.22、html/template、PostgreSQL、React、TypeScript、CSS + +--- + +### Task 1: 为日报 V1 语义层补测试 + +**Files:** +- Modify: `scripts/generate_daily_report_test.go` +- Test: `scripts/generate_daily_report_test.go` + +**Step 1: 写失败测试** + +补 3 组测试: +- 免费来源标签分组测试:验证 `official_free`、`aggregator_free`、`unknown_free` +- V1 首页摘要测试:验证会输出一句话结论、行动建议、头条、附录快捷入口 +- 平静日状态测试:验证当事件不足时,首页出现“观察重点 + 稳定推荐”文案 + +**Step 2: 运行失败测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerate|TestBuild'` + +Expected: +- 新增测试失败 +- 失败原因是缺少语义层函数或 HTML 中不存在新版文案 + +**Step 3: 最小实现语义函数** + +在 `scripts/generate_daily_report.go` 中新增: +- 免费来源分类辅助类型 +- 首页摘要结构 +- 头条 / 建议 / 推荐构建函数 + +**Step 4: 重新运行测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerate|TestBuild'` + +Expected: +- 新增测试通过 + +**Step 5: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go +git commit -m "feat(report): add v1 report summary builders" +``` + +### Task 2: 重构日报数据结构以支撑 V1 页面 + +**Files:** +- Modify: `scripts/generate_daily_report.go` +- Test: `scripts/generate_daily_report_test.go` + +**Step 1: 写失败测试** + +为以下内容补断言: +- 免费模型按来源可信度分组显示 +- 推荐卡存在证据短句 +- 头条卡存在变化基线或“首次出现”标签 + +**Step 2: 运行失败测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateHTMLV3|TestBuild'` + +Expected: +- HTML 内容不包含新版结构字段 + +**Step 3: 最小实现** + +在 `ReportV3` 上新增 V1 所需衍生字段,例如: +- `HeroSummary` +- `ActionItems` +- `HeadlineItems` +- `SceneSections` +- `FreeBreakdown` +- `AppendixLinks` +- `PageMode` + +并在 `generateReportDataV3` 末尾统一填充。 + +**Step 4: 重新运行测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateHTMLV3|TestBuild'` + +Expected: +- 数据结构相关测试通过 + +**Step 5: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go +git commit -m "feat(report): enrich daily report v1 view model" +``` + +### Task 3: 重写 HTML 模板为移动端优先 V1 首页 + +**Files:** +- Modify: `scripts/generate_daily_report.go` +- Test: `scripts/generate_daily_report_test.go` + +**Step 1: 写失败测试** + +断言 HTML 包含以下结构关键词: +- `今日一句话结论` +- `三条行动建议` +- `今日头条` +- `场景推荐` +- `完整数据附录` +- 免费来源标签:`官方免费`、`聚合免费`、`待确认` + +**Step 2: 运行失败测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateHTMLV3Includes'` + +Expected: +- 失败,说明旧模板仍是统计卡 + 表格 + +**Step 3: 最小实现** + +重写 `generateHTMLV3`: +- 使用移动端优先布局 +- 加入结论卡、行动建议卡、头条卡、场景推荐、附录入口 +- 保留必要的完整表格,但下沉到附录区 +- 按设计文档落地颜色、字号、标签、平静日/热点日状态 + +**Step 4: 重新运行测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateHTMLV3Includes'` + +Expected: +- 模板结构测试通过 + +**Step 5: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go +git commit -m "feat(report): redesign html daily report for v1" +``` + +### Task 4: 调整 Markdown 让结构与新版日报一致 + +**Files:** +- Modify: `scripts/generate_daily_report.go` +- Test: `scripts/generate_daily_report_test.go` + +**Step 1: 写失败测试** + +补充 Markdown 断言: +- 顶部出现“今日结论”“今日行动建议”“今日变化” +- 免费区出现来源分类摘要 + +**Step 2: 运行失败测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateMarkdownV3'` + +Expected: +- 旧 Markdown 不包含新版结构 + +**Step 3: 最小实现** + +改写 `generateMarkdownV3` 的章节顺序,至少与 HTML 保持: +- 结论 +- 行动建议 +- 变化摘要 +- 场景推荐 +- 附录 + +**Step 4: 重新运行测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateMarkdownV3'` + +Expected: +- Markdown 测试通过 + +**Step 5: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go +git commit -m "feat(report): align markdown report with v1 structure" +``` + +### Task 5: 补 Dashboard 摘要卡以对齐新版日报 + +**Files:** +- Modify: `frontend/src/pages/Dashboard.tsx` +- Modify: `frontend/src/App.css` + +**Step 1: 写失败测试或构建前检查** + +由于当前前端未配置页面测试,先以类型检查和构建作为验收门槛,并在实现前明确 UI 目标: +- Dashboard 出现一句话摘要 +- 显示报告日期、状态、HTML / Markdown 入口 +- 展示“固定路径回退”提示 +- 视觉上更接近新版日报入口卡 + +**Step 2: 最小实现** + +在 `Dashboard.tsx` 中: +- 扩展 `LatestReport` 展示字段 +- 生成更强的信息层级摘要 +- 调整布局让入口更接近移动端日报卡片语义 + +在 `App.css` 中: +- 为日报入口补新版卡片层级 +- 优化移动端字号和按钮布局 + +**Step 3: 运行构建** + +Run: `cd frontend && npm run build` + +Expected: +- 构建通过 + +**Step 4: Commit** + +```bash +git add frontend/src/pages/Dashboard.tsx frontend/src/App.css +git commit -m "feat(frontend): align dashboard report card with v1 report" +``` + +### Task 6: 真实生成和联调验证 + +**Files:** +- Modify: `reports/daily/*`(生成产物) +- Verify: `scripts/generate_daily_report.go` +- Verify: `scripts/verify_phase3.sh` + +**Step 1: 运行脚本测试** + +Run: `go test -tags llm_script ./scripts` + +Expected: +- 脚本相关测试通过 + +**Step 2: 运行后端测试** + +Run: `go test ./...` + +Expected: +- 全部通过 + +**Step 3: 运行前端构建** + +Run: `cd frontend && npm run build` + +Expected: +- 构建通过 + +**Step 4: 真实生成日报** + +Run: `go run -tags llm_script ./scripts/generate_daily_report.go` + +Expected: +- 生成新版 md/html +- 主产物与归档产物都更新 + +**Step 5: 门禁验证** + +Run: `bash scripts/verify_phase3.sh` + +Expected: +- `PHASE_RESULT: PASS` + +**Step 6: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go frontend/src/pages/Dashboard.tsx frontend/src/App.css reports/daily +git commit -m "feat(report): ship daily report v1 experience" +``` + +### Task 7: 推送到仓库 + +**Files:** +- Verify: working tree + +**Step 1: 检查状态** + +Run: `git status --short` + +Expected: +- 只剩可接受的既有脏文件或已知产物 + +**Step 2: 推送** + +Run: `git push` + +Expected: +- 推送成功 + diff --git a/frontend/src/App.css b/frontend/src/App.css index e249c13..7213543 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -200,6 +200,150 @@ background: #fff; } +.report-section { + margin-bottom: 24px; + padding: 20px; + border: 1px solid #bfdbfe; + border-radius: 12px; + background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%); +} + +.report-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.report-header h3 { + margin: 0; + color: #111827; +} + +.report-header p { + margin: 6px 0 0; + color: #6b7280; + font-size: 14px; +} + +.report-status { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + text-transform: lowercase; +} + +.report-status-generated { + background: #dbeafe; + color: #1d4ed8; +} + +.report-status-other { + background: #e5e7eb; + color: #374151; +} + +.report-card { + padding: 18px; + border: 1px solid #dbeafe; + border-radius: 18px; + background: + radial-gradient(circle at top left, rgba(37, 99, 235, 0.10), transparent 35%), + rgba(255, 255, 255, 0.94); + box-shadow: 0 12px 30px rgba(37, 99, 235, 0.08); +} + +.report-hero { + padding: 18px; + border-radius: 16px; + background: linear-gradient(135deg, #123c63 0%, #24507a 100%); + color: #ffffff; + margin-bottom: 16px; +} + +.report-eyebrow { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.78; + margin-bottom: 8px; +} + +.report-summary { + font-size: 22px; + line-height: 1.35; + font-weight: 800; +} + +.report-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; + color: #4b5563; + font-size: 14px; +} + +.report-highlights { + display: grid; + gap: 10px; + margin-bottom: 14px; +} + +.report-highlight { + display: grid; + gap: 4px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(239, 246, 255, 0.92); + border: 1px solid #dbeafe; +} + +.report-highlight strong { + color: #1e3a8a; + font-size: 13px; +} + +.report-highlight span { + color: #475569; + font-size: 14px; +} + +.report-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.report-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 14px; + border: 1px solid #93c5fd; + border-radius: 8px; + color: #1d4ed8; + text-decoration: none; + font-weight: 600; + background: #ffffff; +} + +.report-link-primary { + background: #2563eb; + border-color: #2563eb; + color: #ffffff; +} + +.report-note { + margin-top: 12px; + color: #6b7280; + font-size: 13px; +} + .subscription-section { padding: 20px; border: 1px solid #fde68a; @@ -302,6 +446,22 @@ flex-direction: column; } + .report-header { + flex-direction: column; + } + + .report-summary { + font-size: 24px; + } + + .report-actions { + flex-direction: column; + } + + .report-link { + width: 100%; + } + .subscription-summary { white-space: normal; } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e326214..7f69d10 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -12,6 +12,46 @@ import { type SubscriptionPlan, } from '../lib/models' +type LatestReport = { + reportDate: string + status: string + modelCount: number + summaryMD: string + markdownUrl: string + htmlUrl: string + updatedAt: string +} + +function formatLocalReportDate(date: Date) { + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +function buildFallbackLatestReport(): LatestReport { + const reportDate = formatLocalReportDate(new Date()) + return { + reportDate, + status: 'generated', + modelCount: 0, + summaryMD: '最新日报入口可用,后端元数据暂未返回摘要。', + markdownUrl: `/reports/daily/daily_report_${reportDate}.md`, + htmlUrl: `/reports/daily/html/daily_report_${reportDate}.html`, + updatedAt: '', + } +} + +function summarizeLatestReport(report: LatestReport) { + if (report.summaryMD.trim()) { + return report.summaryMD.trim() + } + if (report.modelCount > 0) { + return `最新日报已生成,覆盖 ${report.modelCount} 个模型,可直接查看完整 HTML 页面。` + } + return '最新日报入口已准备好,可直接打开 HTML 或 Markdown 查看。' +} + function Dashboard() { const chartRef = useRef移动端优先的情报首页已经上线,这里直接给你最快的入口。
+每日情报报告 · {{.Date}} · {{.TotalModels}} 模型覆盖
-