Compare commits

..

4 Commits

Author SHA1 Message Date
jimme 6c72b7d048 docs: add data source flow guide and localize handoff READMEs
- Add docs/DATA_SOURCE_FLOW.md: end-to-end source -> NotebookLM ->
  storage -> App flow, source list with publish frequency, institution
  intro status, ingestion artifact structure, and known cadence gaps
- Link the new doc from README and PROJECT_OVERVIEW indexes
- Localize top-level and subproject READMEs to Chinese for handoff
  (pre-existing working-tree changes)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:59:38 +09:00
jingyun 556f366894 fix:编译iOS和web目录 2026-06-03 10:17:39 +08:00
jingyun 638cf20629 fix:flutter的sdk过高,适应当前开发版本 2026-06-03 10:17:17 +08:00
jimme fde51468c6 chore: prepare yanting monorepo handoff 2026-06-03 10:39:03 +09:00
196 changed files with 5897 additions and 7932 deletions
+46 -44
View File
@@ -1,47 +1,49 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
# Local/private agent overlays
AGENTS.local.md
CURRENT_STATUS.md
docs.jimme.local/
docs.*.local/
# Secrets and local env
.env
*.env.local
# Python
report-notebooklm-api/.venv/
report-notebooklm-api/.pytest_cache/
report-notebooklm-api/.mypy_cache/
report-notebooklm-api/**/*.pyc
report-notebooklm-api/**/__pycache__/
report-notebooklm-api/*.db
report-notebooklm-api/*.egg-info/
# Flutter / Dart
report-notebooklm-app/.dart_tool/
report-notebooklm-app/.flutter-plugins-dependencies
report-notebooklm-app/.pub-cache/
report-notebooklm-app/.pub/
report-notebooklm-app/build/
report-notebooklm-app/coverage/
# Android local/generated
report-notebooklm-app/android/.gradle/
report-notebooklm-app/android/local.properties
report-notebooklm-app/android/app/debug/
report-notebooklm-app/android/app/profile/
report-notebooklm-app/android/app/release/
report-notebooklm-app/**/*.apk
report-notebooklm-app/**/*.jks
report-notebooklm-app/**/*.keystore
report-notebooklm-app/android/key.properties
# IDE / OS noise
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
**/.DS_Store
.idea/
*.iml
.vscode/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
*.apk
build/verification/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Build artifacts
build/
dist/
coverage/
+126
View File
@@ -0,0 +1,126 @@
# AGENTS.md - Yanting Engineering Repo
> Public agent instructions for this repository. This file is safe to commit.
> Local agents may read ignored `AGENTS.local.md`, but the repository must not depend on it.
> Last updated: 2026-06-03.
## Project
This repository contains the Phase 1 implementation and engineering handoff for `研听 / report-notebooklm`.
`研听` is a Chinese research-report interpretation app. It turns global institutional research reports into structured Chinese reading and listening experiences. The product is an interpretation and annotation service, not investment advice.
Technical identifiers:
- Code/API/internal name: `report-notebooklm`
- Short prefix: `rnb`
- API prefix: `/api/report-notebooklm/v1`
- Database schema name: `report_notebooklm`
- User-facing display name: `研听`
Do not use the user-facing display name in code identifiers, database schema names, Redis keys, object-storage paths, or package names.
## Repository Layout
This is intended to be a single Gitea repository.
| Path | Purpose |
|---|---|
| `README.md` | Human-facing repository entry point. |
| `docs/` | Repo-level public handoff, decisions, and development history. |
| `report-notebooklm-api/` | FastAPI backend, database models, migrations, seed importer, API docs. |
| `report-notebooklm-app/` | Flutter app, Android/web scaffolds, App docs. |
| `docs.jimme.local/` | Ignored local-only notes, not required by the team. |
| `AGENTS.local.md` | Ignored local agent overlay. |
## Public vs Local Documentation
Public, committed documentation must be portable:
- Use repository-relative paths.
- Use environment variables and placeholders for credentials.
- Describe product decisions in team-readable language.
- Distinguish implemented behavior from planned/spec behavior.
Do not commit local-only material:
- Local absolute paths.
- Personal machine setup.
- private agent workflow.
- raw session logs.
- local screenshots, APKs, caches, virtualenvs, build outputs.
- credentials or local service passwords.
Use `docs.jimme.local/` for ignored local notes and raw process references. Durable team-facing conclusions should be distilled into public `docs/`.
## Product and Compliance Constraints
- Public responses expose only reviewed display artifacts, not raw NotebookLM artifacts.
- Public responses expose `cache_version`; `display_version` and module `version` are internal.
- Do not expose raw artifact payloads, local file paths, NotebookLM notebook/source/conversation IDs, account identifiers, or private object-storage paths.
- Phase 1 has no report-interpretation download feature.
- Phase 1 does not include comments, UGC, paid unlocks, membership, task walls, points, trading signals, or investment recommendations.
- NotebookLM-native/source-driven artifacts are the content source. Do not use local LLM rewriting to invent publishable report content.
- Gray broker sources and generated media require compliance/operations review before public release.
## Backend
Read first:
- `report-notebooklm-api/README.md`
- `report-notebooklm-api/docs/HANDOFF.md`
- `report-notebooklm-api/docs/API_AND_DATA.md`
- `report-notebooklm-api/docs/CONTENT_PIPELINE.md`
- `report-notebooklm-api/docs/RUNBOOK.md`
Verify:
```bash
cd report-notebooklm-api
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
alembic upgrade head
python scripts/import_seed_content.py
pytest -q
uvicorn app.main:app --reload --host <bind-host> --port <port>
```
The backend requires `.env` settings for real MySQL/Redis environments. Use `.env.example` as the template. Do not commit `.env`.
## App
Read first:
- `report-notebooklm-app/README.md`
- `report-notebooklm-app/docs/HANDOFF.md`
- `report-notebooklm-app/docs/API_CONTRACT_NOTES.md`
- `report-notebooklm-app/docs/APP_RUNBOOK.md`
Verify:
```bash
cd report-notebooklm-app
flutter analyze
flutter test
flutter build web --dart-define=RNB_API_BASE=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
The App intentionally has no built-in live API default. Always pass `RNB_API_BASE`.
## Decision Records
Long-lived decisions belong in `docs/DECISIONS.md`.
Development timeline and major implementation changes belong in `docs/DEVELOPMENT_HISTORY.md`.
Raw session logs, temporary planning transcripts, or local-only evidence pointers belong in ignored `docs.jimme.local/`.
## Git Rules
- Target remote: `https://gitea.neuronlabs.art/third-party-project/yanting.git`.
- Commit one monorepo, not nested repositories.
- Before the final monorepo push, remove or archive nested `.git/` directories under subprojects so source files are committed as normal directories.
- Keep `.env`, build artifacts, caches, APKs, local status files, and local agent overlays ignored.
- Use English commit messages with prefixes such as `feat:`, `fix:`, `docs:`, and `chore:`.
+104 -41
View File
@@ -1,36 +1,107 @@
# report-notebooklm-app
# 研听 / report-notebooklm
report-notebooklm 第一阶段应用外壳的 Flutter 客户端
`研听` 是一个第一阶段(Phase 1)的应用和后端,用来把全球机构研报转化成结构化的中文阅读与收听体验
后端 API 在同一个 monorepo 的 `../report-notebooklm-api/` 里。API、数据、内容流水线的细节都记在那边;这个目录专注于应用交接、UI 状态、构建命令和对接说明
这个仓库被整理成单个 Gitea 交接仓库,供产品和工程团队接手使用
## 仓库里有什么
| 区域 | 路径 | 说明 |
|---|---|---|
| 后端 API | `report-notebooklm-api/` | FastAPI 服务、MySQL 模型、Alembic 迁移、种子数据导入、对外只读 API。 |
| Flutter 应用 | `report-notebooklm-app/` | Flutter 客户端,包含五个主标签页、研报详情模块、Android/Web 脚手架。 |
| 仓库文档 | `docs/` | 项目级概览、决策记录、开发历程和交接指南。 |
| 后端文档 | `report-notebooklm-api/docs/` | API、数据、内容流水线、运维手册的细节。 |
| 应用文档 | `report-notebooklm-app/docs/` | 应用运维手册、项目地图、API 调用说明。 |
## 产品速览
`研听` 帮助中文用户读懂全球机构研报,覆盖宏观、贵金属、大宗商品、能源、央行、跨资产等主题。
第一阶段聚焦在:
- 推荐:精选 / 最新的研报解读。
- 研报:研报列表和基础筛选。
- 机构:机构列表和机构详情。
- 听单:带音频的研报。
- 我的:游客 / 登录状态,以及浅层的个人状态入口。
第一阶段明确**不包含**:评论、UGC、付费解锁、会员、广告、交易信号、投资建议、研报解读下载。
## 先读这些
- [docs/HANDOFF.md](docs/HANDOFF.md):当前应用状态、已实现的页面、占位项,以及下一步工作。
- [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md):产品和第一阶段范围速览。
- [docs/APP_RUNBOOK.md](docs/APP_RUNBOOK.md)Flutter 版本、本地运行、Web 构建、Android 调试构建和验证。
- [docs/API_CONTRACT_NOTES.md](docs/API_CONTRACT_NOTES.md):应用所消费的接口和字段。
- [docs/PROJECT_MAP.md](docs/PROJECT_MAP.md):源码目录地图。
给人类读者:
## 产品边界
1. `docs/PROJECT_OVERVIEW.md`
2. `docs/DECISIONS.md`
3. `docs/DATA_SOURCE_FLOW.md`
4. `docs/DEVELOPMENT_HISTORY.md`
5. `report-notebooklm-api/docs/HANDOFF.md`
6. `report-notebooklm-app/docs/HANDOFF.md`
这个仓库装的是应用代码和一份工程交接快照,不是产品的唯一真源。
给 AI agent
产品 SSOTmall-docs 里的 report-notebooklm 文档。快照日期:2026-06-03。
1. `AGENTS.md`
2. `docs/DECISIONS.md`
3. 对应子系统的 README 和运维手册。
技术标识符用 `report-notebooklm``rnb`,面向用户的产品名是 `研听`
## 当前实现状态
## 环境要求
后端已实现:
- Flutter 3.44.1 / Dart 3.12.1,或兼容的更新版本
- 一个正在运行、提供 `/api/report-notebooklm/v1` 的后端
- 做 Android 构建还需要:Android SDK、已接受的许可协议,以及一台模拟器或真机
- 挂在 `/api/report-notebooklm/v1` 下的 FastAPI 应用
- 第一阶段数据表的 SQLAlchemy 模型层
- Alembic 初始迁移
- 种子数据导入脚本。
- 健康检查、信息流、研报、研报模块、机构、听单的对外只读接口。
- 针对种子数据和对外 API 行为的测试。
## API 基础地址
应用已实现:
应用刻意不内置任何线上 API 默认值。请显式传入后端基础地址:
- 五个底部标签页:推荐、研报、机构、听单、我的。
- 基于 `RNB_API_BASE` 的列表 / 详情视图。
- 研报详情的模块渲染器注册表。
- 登录、收藏、外链跳转确认、播放进度的本地占位实现。
- Android 和 Web 构建脚手架。
尚未达到生产可用:
- 鉴权和个人状态。
- 真实的音频流签名。
- 外链事件写入。
- 内部内容管理 API。
- 生产环境对象存储和缓存失效。
- 生产 API 域名、发布签名、最终应用图标、应用商店元信息。
## 后端快速上手
```bash
cd report-notebooklm-api
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
# 按你的 MySQL 和 Redis 编辑 .env
alembic upgrade head
python scripts/import_seed_content.py
uvicorn app.main:app --reload --host <bind-host> --port <port>
```
冒烟检查:
```bash
API_BASE_URL=http://<api-host>:<port>/api/report-notebooklm/v1
curl "$API_BASE_URL/health"
curl "$API_BASE_URL/feed/recommended"
curl "$API_BASE_URL/reports/rep_ssga_gold"
```
## 应用快速上手
```bash
cd report-notebooklm-app
flutter analyze
flutter test
flutter run -d chrome --dart-define=RNB_API_BASE=<api-base-url>
```
@@ -40,38 +111,30 @@ Android 模拟器:
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
同一局域网内的 Android 真机:
```bash
flutter run -d <device-id> --dart-define=RNB_API_BASE=http://<host-lan-ip>:<port>/api/report-notebooklm/v1
```
明文 HTTP 只能用于调试构建。发布构建必须使用 HTTPS。
## 验证
后端:
```bash
cd report-notebooklm-api
source .venv/bin/activate
pytest -q
```
应用:
```bash
cd report-notebooklm-app
flutter analyze
flutter test
flutter build web --dart-define=RNB_API_BASE=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## 当前应用范围
## 文档边界
已实现:
这个仓库是一份代码交接快照,不能替代产品的唯一真源(SSOT)。
- 五个底部标签页:推荐、研报、机构、听单、我的
- 基于 API 的信息流、研报列表、机构列表、听单、机构详情和研报详情。
- 用于内联模块和「卡片 + 页面」模块的模块渲染器注册表。
- 产品显示名 `研听`
- 登录、收藏、外链跳转确认、播放进度的本地 UI 占位。
产品 SSOTmall-docs 里的 report-notebooklm 文档,快照日期:2026-06-03
尚未实现:
- 真实鉴权。
- 真实的收藏 / 历史 / 收听记录同步。
- 真正可播放的音频流。
- 真实的外链事件写入。
- 生产 API 域名。
- 发布签名、最终图标和最终应用商店元信息。
仅限本机的笔记、私有路径、原始会话指针、个人 agent 工作流,都应放在被忽略的 `docs.jimme.local/``AGENTS.local.md` 里。
+269
View File
@@ -0,0 +1,269 @@
# 数据源流转说明 / Data Source Flow
这是一份交接快照,不是产品唯一真源(SSOT)。
产品 SSOTmall-docs 的 report-notebooklm 文档,快照日期:2026-06-03。
本文把"研报从哪里来 → 怎么解读 → 存在哪里 → 怎么进 APP"这条链路一次讲清楚,并把之前文档里分散或缺失的部分(尤其是**数据源清单**与**更新频率**)补齐。涉及的具体实现细节请回到各子系统文档与 SSOT 核对。
---
## 1. 一图看懂:四层数据模型 + 端到端流转
研听的数据分**四层**。前两层是内部证据,**对 APP 不可见**;后两层是审核后的展示物,对 APP 可见。
```
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 1 报告源 Report Source │
│ 机构研报 PDF / 来源 URL / 机构元数据 │
│ └─ 来自公开官方源 / 授权伙伴源 / 灰色券商公开源 │
└───────────────────────────────┬─────────────────────────────────────┘
│ 上传到 NotebookLM,源驱动解读
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 2 原始产物 Raw Artifact(内部,App 不可见) │
│ NotebookLM 原生 + 定向查询产物,全量保留 │
│ └─ payload 存对象存储;DB 只存 metadata + payload_ref + sha256 │
└───────────────────────────────┬─────────────────────────────────────┘
│ 确定性组装 / 清洗 / 字段映射 + 人工审核
│ (禁止本地 LLM 重写原文)
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 3 展示产物 Display Artifact(审核后,App 可见) │
│ display_artifacts + display_modules(按 P0/P1/P2 分层的详情模块) │
│ └─ 状态机:missing → raw_ready → review → approved → published │
└───────────────────────────────┬─────────────────────────────────────┘
│ 只读公开 API
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 4 App 响应 App Response │
│ 列表 / 详情骨架 / 模块懒加载 / 音频签名 URL / 机构卡片 │
└─────────────────────────────────────────────────────────────────────┘
```
**核心原则**APP 永远只消费 Layer 3/4 的"审核后展示物",从不直接读 Layer 1/2 的原始 PDF 或 NotebookLM 原始产物。误请求原始产物应返回 `RAW_ARTIFACT_NOT_EXPOSED`403)。
---
## 2. 数据源(Layer 1
### 2.1 三类来源 / 三个可信层级
| 来源类别 | 可信层级 | 处理规则 |
|---|---|---|
| 官方公开源(监管机构、国际组织、行业组织) | `tier_1` | 标准流程。 |
| 卖方研究 / 资管(投行、资管公司、数据商) | `tier_2` | 标准流程。 |
| 灰色券商公开源 | `tier_3` | 更严格审核;来源 URL 展示受限,需走后端短期签名 URL;发布前必须合规/运营复核。 |
| 自家 / 授权合作源 | 按约定 | 暂空,后续接期货公司 / 券商内部研报时新增。 |
来源可作参考的历史经验来自 Vision 的源清单与源健康数据,但**生产数据不得依赖本地 Vision 运行时、本地路径、本地缓存或本地账号状态**。
### 2.2 研报 PDF 源清单与发布频率(补齐)
以下为 SSOTvision-research-sources)中**已启用的研报 PDF 源**,按主题分组,含**天然发布频率**——这正是此前文档缺失的"PDF 更新频率"基线。频率列指**源站自身的研报发布周期**,不等于研听的解读 / 复读节奏(见第 6 节)。
**贵金属专门机构**
| 机构 | 代表报告 | 发布频率 |
|---|---|---|
| World Gold Council(世界黄金协会) | Weekly Markets Monitor / Silver Lining | 周 |
| WPIC(世界铂金投资协会) | 铂金季报 | 季 |
| State Street(道富) | 贵金属月度 | 月 |
| ING | 贵金属 / 外汇研究 | 不定期 |
| Silver Institute(白银协会) | 白银市场 | 年 / 不定期 |
| HDFC Securities / Sharekhan | 印度市场视角 | 不定期 |
| Emirates NBD | 中东 / 央行购金 | 不定期 |
**跨资产 / 大宗宏观(卖方主力)**
| 机构 | 代表报告 | 发布频率 |
|---|---|---|
| Goldman Sachs(高盛研究) | 大宗 / 宏观展望 | 年 / 不定期 |
| J.P. MorganAM + PWM | 资产配置展望 | 年 / 不定期 |
| Bloomberg Intelligence | 跨资产 | 不定期 |
| WisdomTreeEU + US | 大宗商品展望 | 不定期 |
| Invesco(景顺) | ETF / 资产配置 | 年 |
| World Bank(世界银行) | Commodity Markets Outlook / **Pink Sheet** | 半年 / **双周(频率最高)** |
**能源**
| 机构 | 代表报告 | 发布频率 |
|---|---|---|
| EIA(美国能源信息署) | Short-Term Energy Outlook | 月 |
| IEA(国际能源署) | Oil Market Report / Gas Market Report | 月 / 季 |
| OPEC(欧佩克) | 年度展望 | 年 |
| IEEJ(日本能源经济研究所) | 能源经济 | 不定期 |
| Policy Center(摩洛哥) | 能源政策 | 不定期 |
**矿企 / 工业金属 / 农产品**
| 机构 | 代表报告 | 发布频率 |
|---|---|---|
| USGS(美国地质调查局) | Mineral Commodity Summaries | 年 |
| USDA | WASDE 农产品供需 | 月(PDF 链接月度轮换) |
| Eldorado Gold / Pan American Silver | 矿企季报 | 季 |
> 频率总览:**周(WGC)→ 双周(WB Pink Sheet)→ 月(EIA / IEA OMR / USDA / State Street)→ 季(WPIC / IEA Gas / 矿企)→ 半年(WB CMO)→ 年(高盛 / JPM / Invesco / USGS / OPEC**。
**口径优先级**:实际入库表 > Vision `config/research_report_sources.json``config/sources.yaml` > 本文。本文是研听消费视角的聚合视图,会定期 stale,使用前请回源核对。
### 2.3 与种子数据的差异(重要)
后端 `import_seed_content.py` 里的 **18 家机构是种子数据,不是生产清单**。生产权威清单是 §2.2 的 ~31 家 PDF 源。此外:
- 种子里出现的 BIS / Fed / IMF 等,以及早期设计稿设想的 ECB / BOJ,**不在已启用的研报源清单内**——它们是设计设想或实验样本(如 NotebookLM 能力实验用的 BIS 季报),落地时以已启用清单为准。
- 上线前接入新源,应在 Vision 源配置(或后续研听自有源配置)里新增 source,并同步本文。
---
## 3. 机构信息(institutions 表)
| 字段 | 说明 | App 可见 | 现状 |
|---|---|---|---|
| `name_cn` / `name_en` | 中英文名 | 是 | 已填 |
| `institution_type` | 7 类枚举:`official` / `international_org` / `industry_org` / `bank_research` / `asset_manager` / `data_provider` / `partner` | 是 | 已填 |
| `source_tier` | `tier_1/2/3` | 是 | 已填 |
| `website_url` | 官网 | 是 | 已填 |
| `covered_topics` | 覆盖主题 | 是 | 已填 |
| `intro_cn` | **机构详情页简介** | 是 | ⚠️ 字段存在,逐家文本基本未写 |
| `credibility_note` | **可信度说明** | 是 | ⚠️ 仅有 WGC 一条样例 |
**机构介绍现状**schema 完全支持 `intro_cn` + `credibility_note`,但 SSOT 中目前只有一条实际样例——WGC 的可信度说明:"全球黄金行业组织,公开发布黄金需求与市场研究。" §2.2 各机构的"代表报告 / 主题"可作为撰写逐家简介的素材,但**31 家逐家成段介绍文本仍是待补内容**。
---
## 4. PDF → NotebookLM:解读与抓取内容结构(Layer 2)
### 4.1 解读工作流(推荐顺序)
1. 检查源 PDF:标题、机构、日期、页数、大小、报告类型。
2. 为一份报告源创建(或复用)一个 notebook;除非明确做多报告综述,否则一报告一 notebook。
3. 上传报告源。
4. 生成 **P0 文本包**source description、原生 Briefing Doc、原生 Blog Post、data table、query dimensions、query key data、query divergence、query weaknesses。
5. 生成 **P1 产物**query timeline、query related sources、Study Guide、mind map(若导出成功)。
6. 异步生成 **P2 产物**infographic 候选、audio brief、research discovery。
7. 每步操作后写入 manifest,持久化每个产物状态。
8. 从已审核产物**确定性**组装展示模块。
9. 发布前人工审核。
工具链:NotebookLM CLI`nlm`)创建 notebook、上传 source、生成并导出 artifacts;生产 worker 把 PDF 生产为 raw artifacts 并入库。
### 4.2 产物类型(16 类)与实测结构
一次实测(106 页机构季报样本)产出 **16 类 artifact15 成功、1 失败(mind map 导出失败)**,体量从 ~1KB 文本到 5.4MB 信息图、~75 秒音频不等。各类用途与发布约束:
| Artifact 类型 | 用途 | 阻断发布 | 需人审 |
|---|---|:--:|:--:|
| `source_summary` / `notebook_summary` | 源 / notebook 级摘要 | 否 | 否 |
| `native_briefing_doc` | 原生简报文档 | **是** | 否 |
| `native_blog_post` | 原生博文 | **是** | 否 |
| `native_study_guide` | FAQ / 学习指南 / 术语表 | 否 | 否 |
| `data_table` | 结构化表格(CSV | **是** | 否 |
| `mind_map` | 思维导图 / 图结构源 | 否 | 否 |
| `query_dimensions` | 分析维度 | **是** | 否 |
| `query_key_data` | 关键数据点 | **是** | 否 |
| `query_divergence` | 与共识的分歧 | 否 | 否 |
| `query_weaknesses` | 弱点与开放问题 | 否 | 否 |
| `query_timeline` | 时间线与转折点 | 否 | 否 |
| `query_related_sources` | 相关源候选 | 否 | **是** |
| `research_discovery` | 拓展队列 | 否 | **是** |
| `infographic` | 公开候选图 | 否 | **是** |
| `audio_brief` | 音频预览 / 音频源 | 否 | 否 |
> **最高价值层**是 query 系产物(dimensions / key_data / divergence / weaknesses / timeline),体量最大、信息最密。
### 4.3 raw artifact 元数据结构(manifest → 数据库 `raw_artifacts`
每条 artifact 记录持久化的字段:`artifact_type``provider`(默认 notebooklm)、`payload_format``payload_ref`(对象存储引用)、`sha256``size_bytes``status`pending/ok/failed)、`error``generated_at` / `ingested_at``is_publish_blocking``requires_human_review``quality_flags``retention_status`,以及内部关联 IDnotebook / source / conversation——**仅内部,绝不进 App 响应**)。
### 4.4 抓取的两条硬规则
- **禁止本地 LLM 重写** NotebookLM 原文。流水线只能编排、清洗、校验、字段映射、确定性组装、人工裁剪;不得用本地改写凭空生成可发布内容。
- **引用页码需二次规范化**:NotebookLM 引用可能给出研报印刷页码(≠ PDF 物理页码),UI 不暴露 raw page label,未规范化前不展示页标;保留 citation 作内部证据。
---
## 5. 存储与流转落点(Layer 2 → 3)
### 5.1 对象存储(阿里云 OSS
原始 payload、音频、图片、超大模块内容都存 OSS,DB 只存引用键。约定前缀:
| 前缀 | 内容 |
|---|---|
| `rnb/raw/` | NotebookLM 原始产物 payload |
| `rnb/modules/` | 展示模块内容(大模块 `content_ref` |
| `rnb/audio/` | 音频资产 |
| `rnb/images/` | 信息图 / 图片 |
- raw payload 存 OSSMySQL 仅存 `payload_ref` + metadata + `sha256`(内部)。
- 音频对象键 `audio_assets.oss_key` 内部不可见;播放 `stream_url` 由后端**即时签发短期签名 URL**(计划有效期 ~2 小时),不落库、无下载 URL。
- 大模块内容(如 mind map / infographic / 长表,>100KB)存 OSS`display_modules.content` 只存 `content_ref` + `content_etag`
> ⚠️ 当前实现状态:真实 OSS 签名与失效策略仍为 **planned**,本仓库 scaffold 未落地生产对象存储。
### 5.2 数据库表(schema = `report_notebooklm`MySQL 8
共 13 张表:`institutions``reports``raw_artifacts``display_artifacts``display_modules``audio_assets``related_news``users``favorites``reading_history``saved_listens``playback_progress``outbound_events`
- **内容侧**(已实现模型):前 7 张。
- **用户态侧**(已实现模型、API 多为 planned):后 6 张。
### 5.3 raw → display 审核状态机
```
missing → raw_ready → review → approved → published
↑↓
hidden
```
**发布门槛**:所有 `is_publish_blocking=True` 的 P0 模块均已 `published`,且来源署名与风险免责声明齐备、公开响应不含原始 payload / 本地路径 / NotebookLM 内部 ID / 账号信息。
---
## 6. 流转节奏(cadence)与已知缺口
把"频率"分成三个层次看,避免混淆:
| 层次 | 现状 |
|---|---|
| **A. 各源天然发布频率** | ✅ 已明确,见 §2.2(周 / 双周 / 月 / 季 / 半年 / 年)。 |
| **B. 单次 NotebookLM 生产压力策略** | ✅ 已实测:单账号串行(`parallelism=1`)、限速(~48 ops/小时量级)、按产物重量 60–150 秒冷却、不跑 slides/video、research discovery 不自动导入;一篇报告图文层约 20–30 分钟。 |
| **C. 研听自身的解读 / 复读 / 排产 cadence** | ❌ **未冻结**——产品契约层没有定义"每篇研报多久复读一次""每天/每周解读多少篇""生产 runner 的 cron/触发节奏"。 |
**内容量门槛(非频率,但相关)**
- 开发期种子:1020 条 Report / 58 个 Institution / 35 条带音频。
- 上线前首批:30–50 条已审核研报解读,≥10 条带音频。
**仍待补的缺口(建议下一步处理)**
1. **研听生产 cadenceC 层)**:每篇研报的复读周期、每天/每周产量、生产 runner 调度节奏。Phase 1 的定位是"上线前批量跑一次最小内容集,不阻塞 App 开发",**持续 cadence 留给后续阶段(G5 服务端生产链迁移)**,目前仅"每周检查可发布数量"。
2. **机构逐家介绍文本**:§3 的 `intro_cn` / `credibility_note` 31 家逐家内容。
3. **种子清单 vs 生产清单对齐**:把 §2.2 的生产源清单沉淀为正式机构主数据,替换 18 家种子。
---
## 7. 进入 APP 的出口(Layer 4
是的,**最终是"进数据库 + 进对象存储"的双层落地**,APP 通过只读 API 消费:
- 元数据与结构化模块内容 → **MySQL**13 张表)。
- 原始 PDF、原始产物、音频、图片、超大模块 → **对象存储**DB 存引用键。
- 缓存 → Redisfeed/detail 缓存、播放进度去抖、限流)。
**公开 API**(前缀 `/api/report-notebooklm/v1`):`/feed/recommended``/reports``/reports/{id}`(详情骨架)、`/reports/{id}/modules/{module_id}`(重模块全文懒加载)、`/institutions``/institutions/{id}``/listen`,以及计划中的 `/audio/{id}/stream`(短期签名 URL)。
**详情页取数模型**:骨架 + 模块懒加载——轻模块内联返回 `content`;重模块返回 `preview`,全文走二级端点或 `content_ref`,客户端用 `content_etag` 校验缓存。公开已发布内容可直读 `content_ref`;受限(灰色)来源走后端短期签名 URL。
**内部生产链 API**service token + 网络白名单,绝不对 App 暴露):`POST /internal/reports/{id}/raw-artifacts``/display-artifacts``/publish``/hide` 等。发布动作更新展示状态、刷新 `has_audio`、bump `cache_version`、清相关缓存键。
---
## 8. 相关文档
- 内容流水线细节:`report-notebooklm-api/docs/CONTENT_PIPELINE.md`
- API 与数据模型:`report-notebooklm-api/docs/API_AND_DATA.md`
- 运维与存储约定:`report-notebooklm-api/docs/RUNBOOK.md`
- 决策记录:`docs/DECISIONS.md`
- 产品 SSOTmall-docs report-notebooklm 文档(数据源清单、构建 brief、数据模型契约、NotebookLM 能力实验报告)。
+44
View File
@@ -0,0 +1,44 @@
# Decision Record
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Product Decisions
| Date | Decision | Impact |
|---|---|---|
| 2026-06-02 | Phase 1 scope is a Chinese global institutional report interpretation app, not a pure audio app. | Five main tabs remain 推荐 / 研报 / 机构 / 听单 / 我的. |
| 2026-06-02 | Phase 1 has no commercialization. | No ads, paid unlock, membership, task wall, or points. |
| 2026-06-02 | Phase 1 does not open comments, UGC, or user-generated report interpretation. | App should not show community or publishing entry points. |
| 2026-06-02 | Guest users can browse public content and fully listen to at least one item. | Login should not block first listening experience. |
| 2026-06-03 | Product display name is `研听`; technical identifiers stay `report-notebooklm` / `rnb`. | Code identifiers, database schema, Redis keys, object-storage paths, and API prefixes remain brand-neutral. |
| 2026-06-03 | Phase 1 has no report-interpretation download feature. | No top-level download icon, detail download button, profile download record, download API, or offline audio package. |
## API and Data Decisions
| Date | Decision | Impact |
|---|---|---|
| 2026-06-03 | Public responses expose only `cache_version`. | `display_version`, module `version`, and nested cache version objects are internal. |
| 2026-06-03 | Heavy modules use a skeleton plus lazy full-module flow. | Detail returns previews; full content uses `/reports/{report_id}/modules/{module_id}` or a content reference. |
| 2026-06-03 | FAQ, Study Guide, and Glossary are represented as `study_guide`. | Legacy `faq` should map to `study_guide`; no separate public `faq` type. |
| 2026-06-03 | Public published content may use direct content references; restricted sources need short-lived backend signed URLs. | Backend keeps module endpoint and should add signed URL behavior for restricted content. |
| 2026-06-03 | Gray broker sources may be full-text audio-ized, but need compliance/operations review before production. | Seed and production rules can allow audio, but release must remain reviewed. |
## Content Pipeline Decisions
| Date | Decision | Impact |
|---|---|---|
| 2026-06-02 | NotebookLM is treated as a source-driven research engine. | Use native artifacts and targeted queries; do not invent unsupported copy. |
| 2026-06-02 | Raw artifacts stay internal. | App consumes reviewed display artifacts only. |
| 2026-06-02 | P0 text artifacts publish first; media and enrichment are async. | Audio, infographic, research discovery, and mind map must not block text publishability. |
| 2026-06-02 | Vision can be used as source/reference experience but not as a production runtime dependency. | Production data must not depend on local Vision runtime, local paths, or local account state. |
## Repository and Handoff Decisions
| Date | Decision | Impact |
|---|---|---|
| 2026-06-03 | Gitea target is a single repository. | `report-notebooklm-api/` and `report-notebooklm-app/` should be ordinary subdirectories in one repo. |
| 2026-06-03 | Public docs must be portable. | No local absolute paths or private machine setup in committed docs. |
| 2026-06-03 | Local-only agent and status material goes into ignored files. | Use `AGENTS.local.md` and `docs.jimme.local/`. |
| 2026-06-03 | Long-lived decisions are public; raw sessions are local. | Distill decisions into this file; keep session pointers in ignored local docs. |
+85
View File
@@ -0,0 +1,85 @@
# Development History
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## 2026-06-02 - Product Scope Freeze
- Product scope was corrected away from the old "Wall Street listening" / pure-audio framing.
- Phase 1 was frozen around a Chinese research-report interpretation app.
- Main tabs were fixed as 推荐 / 研报 / 机构 / 听单 / 我的.
- Non-goals were made explicit: no commercialization, comments, UGC, trading advice, professional terminal, or local Vision runtime dependency.
- Vision was kept as reference/source experience, not production runtime.
## 2026-06-02 - Development Plan and Review
- Phase 1 technical baseline was selected:
- Flutter App.
- FastAPI backend.
- MySQL 8.
- Redis with `rnb:` namespace.
- Object storage for raw artifacts, heavy modules, audio, and images.
- Existing cloud/server deployment model.
- External launch dependencies were identified:
- SMS template/signature.
- WeChat Open Platform.
- Apple login if required.
- AI-generated-content labeling.
- compliance review for source and media policies.
- The plan passed independent review with changes requested around launch blockers and implementation details.
## 2026-06-03 - Backend Scaffold
- FastAPI service created under `report-notebooklm-api/`.
- SQLAlchemy model layer created for Phase 1 tables.
- Alembic initial migration added.
- Seed importer added with institutions, reports, display artifacts, display modules, audio assets, users, favorites, and playback progress.
- Public read routes implemented:
- `/health`
- `/feed/recommended`
- `/reports`
- `/reports/{id}`
- `/reports/{id}/modules/{module_id}`
- `/institutions`
- `/institutions/{id}`
- `/listen`
- Tests added for seed counts, public API shape, hidden/review module boundaries, gray-source behavior, and listen list behavior.
## 2026-06-03 - App Scaffold
- Flutter app shell created under `report-notebooklm-app/`.
- Five tabs implemented.
- API client added with explicit `RNB_API_BASE`.
- Feature folders created for feed, reports, institutions, listen, profile, detail, and shared widgets.
- Detail module renderer registry added.
- Local placeholders added for blocked behaviors:
- login
- favorite
- outbound confirmation
- playback progress
- real audio stream
- Android platform scaffold added.
## 2026-06-03 - Handoff Preparation
- Backend and App documentation added.
- Public docs were distilled from product documents without copying the full product-doc tree.
- Local-only paths and raw session details were separated from public docs.
- Root README and public AGENTS instructions were introduced for the single-repo Gitea handoff.
## Current Verification Snapshot
Validated during handoff preparation:
- Backend editable install with dev dependencies.
- Backend migration.
- Backend seed import.
- Backend tests.
- Backend smoke checks for health, feed, and report detail.
- App analyze.
- App widget test.
- App web build.
- App debug APK build.
Build artifacts are transient and are not committed.
+45
View File
@@ -0,0 +1,45 @@
# Project Overview
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Purpose
`研听` is a Chinese app for understanding global institutional research reports. It converts difficult English research reports into reviewed Chinese reading and listening experiences.
The product is a research-report interpretation and annotation service. It does not provide investment advice.
## Technical Shape
| Layer | Technology | Path |
|---|---|---|
| App | Flutter | `report-notebooklm-app/` |
| API | FastAPI | `report-notebooklm-api/` |
| Database | MySQL 8 | configured by `RNB_DATABASE_URL` |
| Cache | Redis | configured by `RNB_REDIS_URL` |
| Storage | Object storage | planned for raw artifacts, modules, audio, images |
## Phase 1 Surfaces
- 推荐: latest and curated report interpretations.
- 研报: all published report interpretations with basic filters.
- 机构: institution list, institution detail, and recent reports.
- 听单: audio-backed reports.
- 我的: guest/login state and shallow personal-state entries.
## Key Engineering Principle
The app consumes reviewed display artifacts through the API. Raw NotebookLM artifacts are internal evidence and must not be exposed publicly.
NotebookLM-native content may be cleaned, mapped, reviewed, and assembled deterministically. It must not be silently replaced by local LLM rewriting.
## Repository Documentation
- `README.md`: human entry point.
- `AGENTS.md`: public agent instructions.
- `docs/DECISIONS.md`: durable decisions.
- `docs/DEVELOPMENT_HISTORY.md`: major change history.
- `docs/DATA_SOURCE_FLOW.md`: end-to-end data source flow, source list with publish frequency, and storage/ingestion path.
- `report-notebooklm-api/docs/`: backend, data, API, and content pipeline details.
- `report-notebooklm-app/docs/`: App runbook and API consumption notes.
-2
View File
@@ -1,2 +0,0 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
-2
View File
@@ -1,2 +0,0 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
-43
View File
@@ -1,43 +0,0 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
-26
View File
@@ -1,26 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'app/app.dart';
import 'data/api/report_data_source.dart';
import 'data/providers.dart';
export 'app/app.dart';
export 'data/api/report_data_source.dart';
export 'data/models/models.dart';
class MyApp extends StatelessWidget {
const MyApp({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
reportDataSourceProvider.overrideWithValue(dataSource),
],
child: const ReportNotebooklmApp(),
);
}
}
-40
View File
@@ -1,40 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../routing/app_router.dart';
import '../theme/app_theme.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_shad_theme.dart';
import '../theme/theme_controller.dart';
class ReportNotebooklmApp extends ConsumerWidget {
const ReportNotebooklmApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
final dmSansStyle = GoogleFonts.dmSans().copyWith(
fontFamilyFallback: YantingText.fontFallback,
);
return ShadApp.router(
title: '研听',
debugShowCheckedModeBanner: false,
theme: buildYantingShadTheme(),
darkTheme: buildYantingDarkShadTheme(),
themeMode: themeMode,
routerConfig: router,
scrollBehavior: const ShadScrollBehavior(),
materialThemeBuilder: (context, theme) => buildAppTheme(theme.brightness),
builder: (context, child) {
return DefaultTextStyle.merge(
style: TextStyle(fontFamilyFallback: dmSansStyle.fontFamilyFallback),
child: child ?? const SizedBox.shrink(),
);
},
);
}
}
-8
View File
@@ -1,8 +0,0 @@
import 'package:flutter/widgets.dart';
import 'app.dart';
Future<Widget> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
return const ReportNotebooklmApp();
}
-493
View File
@@ -1,493 +0,0 @@
import '../models/models.dart';
import 'report_data_source.dart';
class MockReportDataSource extends ReportDataSource {
MockReportDataSource();
static final Institution _wgcSummary = _institutionSummary(
id: 'wgc',
nameCn: '世界黄金协会',
nameEn: 'World Gold Council',
logoUrl: 'https://www.google.com/s2/favicons?domain=www.gold.org&sz=128',
institutionType: 'industry_org',
sourceTier: 'A',
websiteUrl: 'https://www.gold.org',
coveredTopics: ['贵金属', '央行', '跨资产'],
reportCount: 6,
introCn: '世界黄金协会致力于推动黄金市场研究与应用,常发布黄金需求、投资与央行购金相关分析。',
credibilityNote: '公开数据、央行储备与 ETF 流量交叉验证,适合跟踪黄金需求结构变化。',
);
static final Institution _bisSummary = _institutionSummary(
id: 'bis',
nameCn: '国际清算银行 BIS',
nameEn: 'Bank for International Settlements',
logoUrl: 'https://www.google.com/s2/favicons?domain=www.bis.org&sz=128',
institutionType: 'official',
sourceTier: 'A',
websiteUrl: 'https://www.bis.org',
coveredTopics: ['宏观', '货币政策'],
reportCount: 4,
introCn: 'BIS 关注全球金融稳定、资本流动和银行体系结构,是宏观与金融市场观察的重要来源。',
credibilityNote: '跨国监管机构,通常以统计和制度性框架解释市场变化。',
);
static final Institution _ieaSummary = _institutionSummary(
id: 'iea',
nameCn: '国际能源署 IEA',
nameEn: 'International Energy Agency',
logoUrl: 'https://www.google.com/s2/favicons?domain=www.iea.org&sz=128',
institutionType: 'official',
sourceTier: 'A',
websiteUrl: 'https://www.iea.org',
coveredTopics: ['能源', '大宗'],
reportCount: 5,
introCn: 'IEA 主要跟踪全球原油、天然气、电力与能源转型相关供需变化。',
credibilityNote: '统计频率高,适合配合库存、产量与政策节奏观察。',
);
static final Institution _worldBankSummary = _institutionSummary(
id: 'worldbank',
nameCn: '世界银行',
nameEn: 'World Bank',
logoUrl:
'https://www.google.com/s2/favicons?domain=www.worldbank.org&sz=128',
institutionType: 'official',
sourceTier: 'A',
websiteUrl: 'https://www.worldbank.org',
coveredTopics: ['大宗', '全球增长'],
reportCount: 3,
introCn: '世界银行常通过大宗商品展望、全球增长与贫困相关研究提供宏观视角。',
credibilityNote: '跨国公共机构,适合作为全球价格和增长基准参考。',
);
static final Institution _ssgaSummary = _institutionSummary(
id: 'ssga',
nameCn: '道富环球投资管理',
nameEn: 'State Street Global Advisors',
logoUrl: 'https://www.google.com/s2/favicons?domain=www.ssga.com&sz=128',
institutionType: 'asset_manager',
sourceTier: 'A',
websiteUrl: 'https://www.ssga.com',
coveredTopics: ['贵金属', '跨资产'],
reportCount: 4,
introCn: '道富的市场观点常围绕资产配置、黄金投资和 ETF 流向展开。',
credibilityNote: '资产管理机构视角偏市场化,适合观察投资者行为和资金流。',
);
static final ReportDetail _gold = ReportDetail(
id: 'gold-demand-q2-2026',
titleCn: '黄金需求趋势:央行与亚洲买家的双轮驱动',
institution: _wgcSummary,
oneLiner: '央行购金与亚洲投资需求,构成本季黄金需求的双轮。',
source: const {'source_tier': 'A', 'source_name': 'World Gold Council'},
topics: const ['贵金属', '央行', '跨资产'],
hasAudio: true,
releasedAt: '2026-05-30',
modules: [
_module('gold-basic', 'basic_info', '基本信息', {
'summary_cn': '央行持续买入黄金,叠加亚洲投资需求回暖,支撑本季金价中枢。',
'topics': ['贵金属', '央行', '跨资产'],
}),
_module('gold-insights', 'core_insights', '核心洞察', {
'points': [
{'kind': 'view', 'text': '央行购金从战术操作转向结构性配置,已成为黄金需求底层支撑。'},
{'kind': 'number', 'text': '金价重回高位后,ETF 与亚洲实物买盘接续承接,波动被更广泛的资金流吸收。'},
{'kind': 'risk', 'text': '若美元和实际利率同步抬升,黄金的防御属性仍会面临短期回撤。'},
],
}, hasDetailPage: true),
_module('gold-audio', 'audio', '音频解读', {
'audio_id': 'audio_gold_q2_2026',
'title_cn': '黄金需求趋势:央行与亚洲买家的双轮驱动',
'duration_sec': 860,
}),
_module('gold-timeline', 'timeline', '时间线', {
'events': [
{'date': '2026-03', 'event': '央行购金维持高位', 'impact': '储备资产配置继续偏向黄金。'},
{'date': '2026-04', 'event': '亚洲投资需求回升', 'impact': '零售和机构买盘共同抬升需求。'},
{
'date': '2026-05',
'event': '金价刷新阶段高位',
'impact': '高位波动加大,但下方买盘承接仍强。',
},
],
}, hasDetailPage: true),
_module('gold-key-data', 'key_data', '关键数据', {
'rows': [
{
'metric': '央行净买入',
'value': '800+',
'unit': '',
'judgment': '仍处高位,支撑黄金中期结构性需求。',
},
{
'metric': 'ETF 流向',
'value': '净流入',
'unit': '',
'judgment': '投资盘开始重新接力。',
},
{
'metric': '亚洲实物需求',
'value': '回升',
'unit': '',
'judgment': '节庆和资产配置需求叠加。',
},
],
}),
_module('gold-study', 'study_guide', '研读指南', {
'intro_cn': '如果你只想看结论,先读核心洞察;如果你想跟踪驱动因素,继续看时间线与关键数据。',
'faq_items': [
{
'question': '为什么央行购金重要?',
'answer': '央行购金往往具有中长期配置属性,可以给金价提供比短线投机更稳定的需求锚。',
},
{
'question': '亚洲买盘如何影响金价?',
'answer': '当亚洲需求在价格高位仍然保持韧性时,金价上方空间通常更容易被市场重新定价。',
},
],
'glossary': [
{'term': 'ETF', 'definition': '交易型开放式基金,常用于观察机构资金流向。'},
{'term': '央行购金', 'definition': '中央银行增持黄金储备的行为。'},
],
}, hasDetailPage: true),
_module('gold-sources', 'related_sources', '相关来源', {
'items': [
{'title': '世界黄金协会季度需求报告', 'summary_cn': '黄金首饰、央行和投资需求的总览。'},
{'title': '道富黄金配置观点', 'summary_cn': '从资产配置角度看黄金在组合中的角色。'},
],
}),
_module('gold-weaknesses', 'weaknesses', '风险与验证', {
'disclaimer_cn': '以下内容不构成投资建议,需结合自身风险承受能力判断。',
'items': [
{
'topic': '美元波动',
'weakness': '若美元在短期内持续走强,黄金可能承压。',
'counter_evidence': '实际利率仍处下行或中性,黄金下方支撑尚在。',
},
{
'topic': '投机拥挤',
'weakness': '当资金快速涌入时,行情容易出现脉冲式回调。',
'counter_evidence': '央行和长期配置资金可部分对冲短线拥挤。',
},
],
'verification_notes': ['关注央行月度购金变化。', '关注 ETF 净流入是否持续两周以上。'],
}),
_module('gold-compliance', 'source_compliance', '来源与合规', {
'source_note': '来源以公开研报、统计口径和机构原文为准,结构化内容用于中文解读。',
'copyright_cn': '版权归原始发布机构与作者所有。',
'disclaimer': '本内容为公开/授权研报的结构化解读,不构成投资建议。',
}),
_module('gold-institution', 'institution', '机构', {
'name_cn': '世界黄金协会',
'report_count': 6,
}),
],
);
static final ReportDetail _bis = ReportDetail(
id: 'bis-quarterly-2026-q1',
titleCn: 'BIS 季报 2026年3月:市场重新校准',
institution: _bisSummary,
oneLiner: '表面平静之下,全球金融市场正经历深刻的流向切换与重新校准。',
source: const {'source_tier': 'A', 'source_name': 'BIS'},
topics: const ['宏观', '货币政策'],
hasAudio: true,
releasedAt: '2026-03-05',
modules: [
_module('bis-basic', 'basic_info', '基本信息', {
'summary_cn': 'BIS 关注全球宏观流动、资本配置和金融体系的再平衡。',
'topics': ['宏观', '货币政策'],
}),
_module('bis-insights', 'core_insights', '核心洞察', {
'points': [
{'kind': 'view', 'text': '市场定价已从单一利率路径转向对增长、通胀和资本流动的综合校准。'},
{'kind': 'number', 'text': '跨境资本流向的再分配比利率本身更能解释当下的市场分化。'},
],
}, hasDetailPage: true),
_module('bis-audio', 'audio', '音频解读', {
'audio_id': 'audio_bis_q1_2026',
'title_cn': 'BIS 季报 2026年3月:市场重新校准',
'duration_sec': 1040,
}),
_module('bis-compliance', 'source_compliance', '来源与合规', {
'source_note': '该模块用于展示机构原文与结构化解读的对应关系。',
'copyright_cn': '版权归 BIS 原始报告与作者所有。',
'disclaimer': '本内容为公开/授权研报的结构化解读,不构成投资建议。',
}),
_module('bis-institution', 'institution', '机构', {
'name_cn': '国际清算银行 BIS',
'report_count': 4,
}),
],
);
static final ReportDetail _iea = ReportDetail(
id: 'iea-oil-monthly-2026-05',
titleCn: '石油市场月报:需求放缓,供给为何仍偏紧?',
institution: _ieaSummary,
oneLiner: '需求增速放缓,但减产纪律与库存偏低支撑油价下方。',
source: const {'source_tier': 'A', 'source_name': 'IEA'},
topics: const ['能源', '大宗'],
hasAudio: true,
releasedAt: '2026-05-20',
modules: [
_module('iea-basic', 'basic_info', '基本信息', {
'summary_cn': 'IEA 月报聚焦全球原油供需、库存和宏观风险。',
'topics': ['能源', '大宗'],
}),
_module('iea-insights', 'core_insights', '核心洞察', {
'points': [
{'kind': 'view', 'text': '需求放缓并不自动意味着价格回落,关键还是供给纪律和库存水平。'},
{'kind': 'number', 'text': '库存偏低时,价格对中短期扰动的敏感度明显提高。'},
],
}, hasDetailPage: true),
_module('iea-audio', 'audio', '音频解读', {
'audio_id': 'audio_iea_2026_05',
'title_cn': '石油市场月报:需求放缓,供给为何仍偏紧?',
'duration_sec': 1120,
}),
_module('iea-key-data', 'key_data', '关键数据', {
'rows': [
{
'metric': '需求增速',
'value': '放缓',
'unit': '',
'judgment': '不代表价格立刻下行。',
},
{
'metric': '供给纪律',
'value': '偏紧',
'unit': '',
'judgment': '减产执行仍在延续。',
},
{'metric': '库存', 'value': '偏低', 'unit': '', 'judgment': '对价格形成支撑。'},
],
}),
_module('iea-compliance', 'source_compliance', '来源与合规', {
'source_note': 'IEA 原文与统计框架保持一致,中文内容仅用于结构化阅读。',
'copyright_cn': '版权归原始发布机构与作者所有。',
'disclaimer': '本内容为公开/授权研报的结构化解读,不构成投资建议。',
}),
_module('iea-institution', 'institution', '机构', {
'name_cn': '国际能源署',
'report_count': 5,
}),
],
);
static final ReportDetail _worldBank = ReportDetail(
id: 'worldbank-commodities-2026-04',
titleCn: '世界银行大宗商品展望:价格见顶了吗?',
institution: _worldBankSummary,
oneLiner: '全球增长预期放缓,但价格路径仍受供给侧扰动影响。',
source: const {'source_tier': 'A', 'source_name': 'World Bank'},
topics: const ['大宗', '全球增长'],
hasAudio: true,
releasedAt: '2026-04-18',
modules: [
_module('wb-basic', 'basic_info', '基本信息', {
'summary_cn': '世界银行展望通常从全球增长、贸易和大宗商品价格三个层面展开。',
'topics': ['大宗', '全球增长'],
}),
_module('wb-insights', 'core_insights', '核心洞察', {
'points': [
{'kind': 'view', 'text': '价格是否见顶,往往取决于供给扰动能否被需求放缓完全吸收。'},
{'kind': 'risk', 'text': '若全球增长进一步放缓,大宗商品的边际弹性会显著下降。'},
],
}, hasDetailPage: true),
_module('wb-audio', 'audio', '音频解读', {
'audio_id': 'audio_worldbank_2026_04',
'title_cn': '世界银行大宗商品展望:价格见顶了吗?',
'duration_sec': 980,
}),
_module('wb-compliance', 'source_compliance', '来源与合规', {
'source_note': '世界银行报告适合作为全球商品价格的基准视角。',
'copyright_cn': '版权归原始发布机构与作者所有。',
'disclaimer': '本内容为公开/授权研报的结构化解读,不构成投资建议。',
}),
_module('wb-institution', 'institution', '机构', {
'name_cn': '世界银行',
'report_count': 3,
}),
],
);
static final List<ReportDetail> _details = [_gold, _bis, _iea, _worldBank];
static final Map<String, ReportDetail> _detailById = {
for (final detail in _details) detail.id: detail,
};
static final Map<String, Institution> _institutionDetails = {
'wgc': _institutionDetail(
base: _wgcSummary,
recentReports: [_gold.asCard()],
),
'bis': _institutionDetail(
base: _bisSummary,
recentReports: [_bis.asCard()],
),
'iea': _institutionDetail(
base: _ieaSummary,
recentReports: [_iea.asCard()],
),
'worldbank': _institutionDetail(
base: _worldBankSummary,
recentReports: [_worldBank.asCard()],
),
'ssga': _institutionDetail(
base: _ssgaSummary,
recentReports: [_gold.asCard()],
),
};
static final Map<String, ModuleDetail> _moduleDetails = {
'gold-insights': _moduleDetail(_gold.modules[1]),
'gold-timeline': _moduleDetail(_gold.modules[3]),
'gold-study': _moduleDetail(_gold.modules[5]),
'bis-insights': _moduleDetail(_bis.modules[1]),
'iea-insights': _moduleDetail(_iea.modules[1]),
'wb-insights': _moduleDetail(_worldBank.modules[1]),
};
@override
Future<List<ReportCardModel>> recommended() async => [
_gold.asCard(),
_bis.asCard(),
_iea.asCard(),
];
@override
Future<List<ReportCardModel>> reports() async =>
_details.map((d) => d.asCard()).toList();
@override
Future<List<Institution>> institutions() async {
return _institutionDetails.values.toList()
..sort((a, b) => b.reportCount.compareTo(a.reportCount));
}
@override
Future<Institution> institutionDetail(String institutionId) async {
return _institutionDetails[institutionId] ?? _institutionDetails['wgc']!;
}
@override
Future<List<AudioItem>> listen() async {
return [
_audioFromReport(_gold, 860),
_audioFromReport(_bis, 1040),
_audioFromReport(_iea, 1120),
_audioFromReport(_worldBank, 980),
];
}
@override
Future<ReportDetail> reportDetail(String reportId) async {
return _detailById[reportId] ?? _gold;
}
@override
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
return _moduleDetails[moduleId] ??
ModuleDetail(
id: moduleId,
type: 'basic_info',
titleCn: '模块详情',
content: const {'preview_summary': '该模块暂无单独详情数据。'},
);
}
static Institution _institutionSummary({
required String id,
required String nameCn,
required String nameEn,
required String logoUrl,
required String institutionType,
required String sourceTier,
required String websiteUrl,
required List<String> coveredTopics,
required int reportCount,
required String introCn,
required String credibilityNote,
}) {
return Institution(
id: id,
nameCn: nameCn,
nameEn: nameEn,
logoUrl: logoUrl,
institutionType: institutionType,
sourceTier: sourceTier,
websiteUrl: websiteUrl,
coveredTopics: coveredTopics,
reportCount: reportCount,
introCn: introCn,
credibilityNote: credibilityNote,
);
}
static Institution _institutionDetail({
required Institution base,
required List<ReportCardModel> recentReports,
}) {
return Institution(
id: base.id,
nameCn: base.nameCn,
nameEn: base.nameEn,
logoUrl: base.logoUrl,
institutionType: base.institutionType,
sourceTier: base.sourceTier,
websiteUrl: base.websiteUrl,
coveredTopics: base.coveredTopics,
reportCount: base.reportCount,
latestReportAt: base.latestReportAt,
credibilityNote: base.credibilityNote,
introCn: base.introCn,
recentReports: recentReports,
);
}
static DisplayModule _module(
String id,
String type,
String titleCn,
JsonMap content, {
bool hasDetailPage = false,
String renderMode = 'inline',
}) {
return DisplayModule(
id: id,
type: type,
titleCn: titleCn,
renderMode: renderMode,
hasDetailPage: hasDetailPage,
content: content,
preview: content,
);
}
static ModuleDetail _moduleDetail(DisplayModule module) {
return ModuleDetail(
id: module.id,
type: module.type,
titleCn: module.titleCn,
content: module.content,
contentEtag: 'mock',
cacheVersion: 'mock',
);
}
static AudioItem _audioFromReport(ReportDetail report, int duration) {
return AudioItem(
audioId: 'audio_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: duration,
institution: report.institution,
releasedAt: report.releasedAt,
cacheVersion: 'mock',
);
}
}
-96
View File
@@ -1,96 +0,0 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'models/models.dart';
import '../widgets/mini_player.dart';
class AudioPlayerController extends StateNotifier<PlayerStateModel> {
AudioPlayerController() : super(const PlayerStateModel());
Timer? _timer;
void startAudio({
required String audioId,
required String reportId,
required String title,
required int durationSec,
}) {
_timer?.cancel();
state = PlayerStateModel(
audioId: audioId,
reportId: reportId,
title: title,
durationSec: durationSec,
playing: true,
speed: state.speed,
);
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
void startFromItem(AudioItem item) {
startAudio(
audioId: item.audioId,
reportId: item.reportId,
title: item.titleCn,
durationSec: item.durationSec,
);
}
void startModuleAudio(
String audioId,
String reportId,
String title,
int durationSec,
) {
startAudio(
audioId: audioId,
reportId: reportId,
title: title,
durationSec: durationSec,
);
}
void toggleAudio() {
if (!state.hasAudio) return;
state = state.copyWith(playing: !state.playing);
if (state.playing && _timer == null) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
}
void seekAudio(int delta) {
if (!state.hasAudio) return;
state = state.copyWith(
positionSec: (state.positionSec + delta).clamp(0, state.durationSec),
);
}
void cycleSpeed() {
const speeds = [1.0, 1.25, 1.5, 2.0];
final current = speeds.indexOf(state.speed);
state = state.copyWith(speed: speeds[(current + 1) % speeds.length]);
}
void _tick() {
if (!state.playing) return;
final step = state.speed.round().clamp(1, 2);
final next = state.positionSec + step;
if (next >= state.durationSec) {
state = state.copyWith(
positionSec: state.durationSec,
playing: false,
);
_timer?.cancel();
_timer = null;
return;
}
state = state.copyWith(positionSec: next);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
-69
View File
@@ -1,69 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'models/models.dart';
import 'providers.dart';
import 'state/app_state_controllers.dart';
import 'state/report_query.dart';
final recommendedReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.recommended();
});
final recommendedByTopicProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
final topic = ref.watch(recommendTopicProvider);
return repository.getRecommended(topic: topic);
});
final reportsProvider = FutureProvider.autoDispose<List<ReportCardModel>>((
ref,
) async {
final dataSource = ref.watch(reportDataSourceProvider);
return dataSource.reports();
});
final filteredReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
final query = ref.watch(reportFilterProvider);
return repository.getReports(query);
});
final institutionsProvider = FutureProvider.autoDispose<List<Institution>>((
ref,
) async {
final repository = ref.watch(reportRepositoryProvider);
return repository.getInstitutions();
});
final listenProvider = FutureProvider.autoDispose<List<AudioItem>>((ref) async {
final repository = ref.watch(reportRepositoryProvider);
return repository.getListenItems();
});
final profileHistoryReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.history);
});
final profileFavoriteReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.favorites);
});
final profileSavedListenReportsProvider =
FutureProvider.autoDispose<List<ReportCardModel>>((ref) async {
final profile = ref.watch(profileControllerProvider);
final repository = ref.watch(reportRepositoryProvider);
final reports = await repository.getReports(const ReportQuery());
return ProfileListBuilder(reports).byIds(profile.savedListens);
});
-70
View File
@@ -1,70 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'api/mock_report_data_source.dart';
import 'api/report_data_source.dart';
import 'audio_player_controller.dart';
import 'repositories/outbound_repository.dart';
import 'repositories/report_repository.dart';
import 'repositories/user_state_repository.dart';
import 'state/app_interaction_state.dart';
import 'state/app_state_controllers.dart';
import 'state/report_query.dart';
import '../widgets/mini_player.dart';
final reportDataSourceProvider = Provider<ReportDataSource>((ref) {
const useMock = bool.fromEnvironment('YANTING_USE_MOCK', defaultValue: true);
if (useMock) {
return MockReportDataSource();
}
return RnbApiDataSource();
});
final audioPlayerControllerProvider =
StateNotifierProvider<AudioPlayerController, PlayerStateModel>((ref) {
return AudioPlayerController();
});
final reportRepositoryProvider = Provider<ReportRepository>((ref) {
return ref.watch(reportDataSourceProvider);
});
final userStateRepositoryProvider = Provider<UserStateRepository>((ref) {
return MemoryUserStateRepository();
});
final outboundRepositoryProvider = Provider<OutboundRepository>((ref) {
return MemoryOutboundRepository();
});
final recommendTopicProvider =
StateNotifierProvider<RecommendTopicController, String>((ref) {
return RecommendTopicController();
});
final reportFilterProvider =
StateNotifierProvider<ReportFilterController, ReportQuery>((ref) {
return ReportFilterController();
});
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
(ref) {
return AuthController(ref.watch(userStateRepositoryProvider));
},
);
final profileControllerProvider =
StateNotifierProvider<ProfileController, ProfileState>((ref) {
return ProfileController(ref.watch(userStateRepositoryProvider));
});
final detailNavigationProvider =
StateNotifierProvider<DetailNavigationController, DetailNavigationState>((
ref,
) {
return DetailNavigationController();
});
final sheetControllerProvider =
StateNotifierProvider<SheetController, SheetState>((ref) {
return SheetController();
});
@@ -1,16 +0,0 @@
import '../state/app_interaction_state.dart';
abstract class OutboundRepository {
Future<void> recordOutbound(OutboundEvent event);
}
class MemoryOutboundRepository implements OutboundRepository {
final List<OutboundEvent> _events = [];
List<OutboundEvent> get events => List.unmodifiable(_events);
@override
Future<void> recordOutbound(OutboundEvent event) async {
_events.add(event);
}
}
@@ -1,12 +0,0 @@
import '../models/models.dart';
import '../state/report_query.dart';
abstract class ReportRepository {
Future<List<ReportCardModel>> getRecommended({String? topic});
Future<List<ReportCardModel>> getReports(ReportQuery query);
Future<ReportDetail> getReportDetail(String reportId);
Future<List<Institution>> getInstitutions();
Future<Institution> getInstitutionDetail(String institutionId);
Future<List<AudioItem>> getListenItems();
Future<ModuleDetail> getModuleDetail(String reportId, String moduleId);
}
@@ -1,94 +0,0 @@
import '../state/app_interaction_state.dart';
abstract class UserStateRepository {
Future<bool> isLoggedIn();
Future<void> login(LoginMethod method, {String? phone});
Future<void> logout();
Future<String?> getPhone();
Future<LoginMethod?> getLoginMethod();
Future<Set<String>> getFavorites();
Future<void> toggleFavorite(String reportId);
Future<Set<String>> getSavedListens();
Future<void> toggleSavedListen(String reportId);
Future<List<String>> getHistory();
Future<void> addHistory(String reportId);
Future<Map<String, double>> getAudioProgress();
Future<void> saveAudioProgress(String audioId, double seconds);
}
class MemoryUserStateRepository implements UserStateRepository {
bool _loggedIn = false;
String? _phone;
LoginMethod? _loginMethod;
final Set<String> _favorites = {};
final Set<String> _savedListens = {};
final List<String> _history = [];
final Map<String, double> _audioProgress = {};
@override
Future<bool> isLoggedIn() async => _loggedIn;
@override
Future<void> login(LoginMethod method, {String? phone}) async {
_loggedIn = true;
_loginMethod = method;
_phone = phone;
}
@override
Future<void> logout() async {
_loggedIn = false;
_phone = null;
_loginMethod = null;
}
@override
Future<String?> getPhone() async => _phone;
@override
Future<LoginMethod?> getLoginMethod() async => _loginMethod;
@override
Future<Set<String>> getFavorites() async => {..._favorites};
@override
Future<void> toggleFavorite(String reportId) async {
if (!_favorites.add(reportId)) {
_favorites.remove(reportId);
}
}
@override
Future<Set<String>> getSavedListens() async => {..._savedListens};
@override
Future<void> toggleSavedListen(String reportId) async {
if (!_savedListens.add(reportId)) {
_savedListens.remove(reportId);
}
}
@override
Future<List<String>> getHistory() async => [..._history];
@override
Future<void> addHistory(String reportId) async {
_history.remove(reportId);
_history.insert(0, reportId);
if (_history.length > 40) {
_history.removeRange(40, _history.length);
}
}
@override
Future<Map<String, double>> getAudioProgress() async => {..._audioProgress};
@override
Future<void> saveAudioProgress(String audioId, double seconds) async {
_audioProgress[audioId] = seconds < 0 ? 0 : seconds;
}
}
-176
View File
@@ -1,176 +0,0 @@
import '../models/models.dart';
class AuthState {
const AuthState({
this.loggedIn = false,
this.pendingAction,
this.phone,
this.loginMethod,
});
final bool loggedIn;
final PendingLoginAction? pendingAction;
final String? phone;
final LoginMethod? loginMethod;
AuthState copyWith({
bool? loggedIn,
Object? pendingAction = _sentinel,
Object? phone = _sentinel,
Object? loginMethod = _sentinel,
}) {
return AuthState(
loggedIn: loggedIn ?? this.loggedIn,
pendingAction: identical(pendingAction, _sentinel)
? this.pendingAction
: pendingAction as PendingLoginAction?,
phone: identical(phone, _sentinel) ? this.phone : phone as String?,
loginMethod: identical(loginMethod, _sentinel)
? this.loginMethod
: loginMethod as LoginMethod?,
);
}
}
class PendingLoginAction {
const PendingLoginAction({
required this.action,
required this.reportId,
required this.contextText,
});
final LoginRequiredAction action;
final String reportId;
final String contextText;
}
enum LoginRequiredAction { favorite, saveListen }
enum LoginMethod { phone, wechat, apple }
class ProfileState {
const ProfileState({
this.favorites = const {},
this.savedListens = const {},
this.history = const [],
});
final Set<String> favorites;
final Set<String> savedListens;
final List<String> history;
ProfileState copyWith({
Set<String>? favorites,
Set<String>? savedListens,
List<String>? history,
}) {
return ProfileState(
favorites: favorites ?? this.favorites,
savedListens: savedListens ?? this.savedListens,
history: history ?? this.history,
);
}
}
class DetailNavigationState {
const DetailNavigationState({
this.originTab = AppTab.recommend,
this.stack = const [],
this.tabScroll = const {},
});
final AppTab originTab;
final List<DetailStackEntry> stack;
final Map<AppTab, double> tabScroll;
DetailNavigationState copyWith({
AppTab? originTab,
List<DetailStackEntry>? stack,
Map<AppTab, double>? tabScroll,
}) {
return DetailNavigationState(
originTab: originTab ?? this.originTab,
stack: stack ?? this.stack,
tabScroll: tabScroll ?? this.tabScroll,
);
}
}
class DetailStackEntry {
const DetailStackEntry({
required this.type,
required this.id,
this.scrollTop = 0,
});
final DetailEntryType type;
final String id;
final double scrollTop;
DetailStackEntry copyWith({double? scrollTop}) {
return DetailStackEntry(
type: type,
id: id,
scrollTop: scrollTop ?? this.scrollTop,
);
}
}
enum DetailEntryType { report, institution }
enum AppTab { recommend, reports, institutions, listen, profile }
class SheetState {
const SheetState.hidden() : intent = null;
const SheetState.visible(this.intent);
final SheetIntent? intent;
bool get isVisible => intent != null;
}
sealed class SheetIntent {
const SheetIntent();
}
class LoginSheetIntent extends SheetIntent {
const LoginSheetIntent({required this.contextText});
final String contextText;
}
class FilterSheetIntent extends SheetIntent {
const FilterSheetIntent();
}
class OutboundSheetIntent extends SheetIntent {
const OutboundSheetIntent({required this.scene, this.refId, this.targetUrl});
final String scene;
final String? refId;
final String? targetUrl;
}
class ProfileListSheetIntent extends SheetIntent {
const ProfileListSheetIntent({
required this.kind,
required this.title,
this.reports = const [],
});
final ProfileListKind kind;
final String title;
final List<ReportCardModel> reports;
}
enum ProfileListKind { favorites, history, saved }
class OutboundEvent {
const OutboundEvent({required this.scene, this.refId, this.targetUrl});
final String scene;
final String? refId;
final String? targetUrl;
}
const Object _sentinel = Object();
-168
View File
@@ -1,168 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/models.dart';
import '../repositories/user_state_repository.dart';
import 'app_interaction_state.dart';
import 'report_query.dart';
class RecommendTopicController extends StateNotifier<String> {
RecommendTopicController() : super('全部');
void select(String topic) {
state = topic;
}
}
class ReportFilterController extends StateNotifier<ReportQuery> {
ReportFilterController() : super(const ReportQuery());
void setSearch(String value) {
state = state.copyWith(search: value);
}
void setTopic(String? topic) {
state = state.copyWith(topic: topic);
}
void setInstitution(String? institutionId) {
state = state.copyWith(institutionId: institutionId);
}
void toggleAudio() {
state = state.copyWith(hasAudio: !state.hasAudio);
}
void setSort(ReportSort sort) {
state = state.copyWith(sort: sort);
}
void reset() {
state = const ReportQuery();
}
}
class AuthController extends StateNotifier<AuthState> {
AuthController(this._repository) : super(const AuthState()) {
_load();
}
final UserStateRepository _repository;
Future<void> _load() async {
state = state.copyWith(
loggedIn: await _repository.isLoggedIn(),
phone: await _repository.getPhone(),
loginMethod: await _repository.getLoginMethod(),
);
}
void requireLogin(PendingLoginAction action) {
if (state.loggedIn) return;
state = state.copyWith(pendingAction: action);
}
Future<PendingLoginAction?> login(LoginMethod method, {String? phone}) async {
final pending = state.pendingAction;
await _repository.login(method, phone: phone);
state = AuthState(
loggedIn: true,
phone: phone ?? await _repository.getPhone(),
loginMethod: method,
);
return pending;
}
Future<void> logout() async {
await _repository.logout();
state = const AuthState();
}
void clearPending() {
state = state.copyWith(pendingAction: null);
}
}
class ProfileController extends StateNotifier<ProfileState> {
ProfileController(this._repository) : super(const ProfileState()) {
refresh();
}
final UserStateRepository _repository;
Future<void> refresh() async {
state = ProfileState(
favorites: await _repository.getFavorites(),
savedListens: await _repository.getSavedListens(),
history: await _repository.getHistory(),
);
}
Future<void> toggleFavorite(String reportId) async {
await _repository.toggleFavorite(reportId);
await refresh();
}
Future<void> toggleSavedListen(String reportId) async {
await _repository.toggleSavedListen(reportId);
await refresh();
}
Future<void> addHistory(String reportId) async {
await _repository.addHistory(reportId);
await refresh();
}
}
class DetailNavigationController extends StateNotifier<DetailNavigationState> {
DetailNavigationController() : super(const DetailNavigationState());
void rememberTabScroll(AppTab tab, double scrollTop) {
state = state.copyWith(tabScroll: {...state.tabScroll, tab: scrollTop});
}
void push(DetailStackEntry entry, {required AppTab originTab}) {
final stack = [...state.stack, entry];
state = state.copyWith(originTab: originTab, stack: stack);
}
void updateCurrentScroll(double scrollTop) {
if (state.stack.isEmpty) return;
final stack = [...state.stack];
stack[stack.length - 1] = stack.last.copyWith(scrollTop: scrollTop);
state = state.copyWith(stack: stack);
}
DetailStackEntry? pop() {
if (state.stack.isEmpty) return null;
final stack = [...state.stack]..removeLast();
state = state.copyWith(stack: stack);
return stack.isEmpty ? null : stack.last;
}
void reset() {
state = const DetailNavigationState();
}
}
class SheetController extends StateNotifier<SheetState> {
SheetController() : super(const SheetState.hidden());
void show(SheetIntent intent) {
state = SheetState.visible(intent);
}
void hide() {
state = const SheetState.hidden();
}
}
class ProfileListBuilder {
const ProfileListBuilder(this.reports);
final List<ReportCardModel> reports;
List<ReportCardModel> byIds(Iterable<String> ids) {
final byId = {for (final report in reports) report.id: report};
return ids.map((id) => byId[id]).whereType<ReportCardModel>().toList();
}
}
-44
View File
@@ -1,44 +0,0 @@
class ReportQuery {
const ReportQuery({
this.search = '',
this.topic,
this.institutionId,
this.hasAudio = false,
this.sort = ReportSort.latest,
});
final String search;
final String? topic;
final String? institutionId;
final bool hasAudio;
final ReportSort sort;
bool get hasActiveFilter =>
search.trim().isNotEmpty ||
topic != null ||
institutionId != null ||
hasAudio ||
sort != ReportSort.latest;
ReportQuery copyWith({
String? search,
Object? topic = _sentinel,
Object? institutionId = _sentinel,
bool? hasAudio,
ReportSort? sort,
}) {
return ReportQuery(
search: search ?? this.search,
topic: identical(topic, _sentinel) ? this.topic : topic as String?,
institutionId: identical(institutionId, _sentinel)
? this.institutionId
: institutionId as String?,
hasAudio: hasAudio ?? this.hasAudio,
sort: sort ?? this.sort,
);
}
}
enum ReportSort { latest, oldest }
const Object _sentinel = Object();
-415
View File
@@ -1,415 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
class LoginPage extends HookConsumerWidget {
const LoginPage({super.key, this.next});
final String? next;
String _backTargetFromNext() {
final rawNext = next;
if (rawNext == null || rawNext.isEmpty) return AppRoutes.profile;
final decoded = Uri.decodeComponent(rawNext);
final uri = Uri.tryParse(decoded);
if (uri == null) return AppRoutes.profile;
if (!uri.path.startsWith('/')) return AppRoutes.profile;
return decoded;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final phoneController = useTextEditingController();
final codeController = useTextEditingController();
final codeFocusNode = useFocusNode();
final codeSent = useState(false);
final sendingCode = useState(false);
final verifying = useState(false);
final countdown = useState(0);
final error = useState<String?>(null);
final agreed = useState(false);
final timerRef = useRef<Timer?>(null);
final auth = ref.watch(authControllerProvider);
final theme = ShadTheme.of(context);
useEffect(() {
return () {
timerRef.value?.cancel();
};
}, const []);
Future<void> sendCode() async {
final phone = phoneController.text.trim();
if (phone.length != 11) {
error.value = '请输入正确的手机号';
return;
}
sendingCode.value = true;
error.value = null;
try {
codeSent.value = true;
countdown.value = 60;
timerRef.value?.cancel();
timerRef.value = Timer.periodic(const Duration(seconds: 1), (timer) {
if (countdown.value <= 1) {
timer.cancel();
countdown.value = 0;
} else {
countdown.value = countdown.value - 1;
}
});
codeFocusNode.requestFocus();
} finally {
sendingCode.value = false;
}
}
Future<void> verify() async {
final phone = phoneController.text.trim();
final code = codeController.text.trim();
if (phone.length != 11) {
error.value = '请输入正确的手机号';
return;
}
if (code.length != 6) {
error.value = '请输入 6 位验证码';
return;
}
if (!agreed.value) {
error.value = '请先同意用户协议和隐私政策';
return;
}
verifying.value = true;
error.value = null;
try {
await ref
.read(authControllerProvider.notifier)
.login(LoginMethod.phone, phone: phone);
await ref.read(profileControllerProvider.notifier).refresh();
if (!context.mounted) return;
showAppToast(context, '已登录 ${maskPhone(phone)}');
final nextPath = next?.trim();
if (nextPath != null && nextPath.isNotEmpty) {
context.go(Uri.decodeComponent(nextPath));
} else if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.profile);
}
} finally {
verifying.value = false;
}
}
Future<void> submitLogin() async {
final phone = phoneController.text.trim();
final code = codeController.text.trim();
if (phone.length != 11) {
error.value = '请输入正确的手机号';
return;
}
if (code.length != 6) {
error.value = '请输入 6 位验证码';
return;
}
await verify();
}
void showPrivacyCheckDialog(VoidCallback onAgreed) {
showModalBottomSheet<void>(
context: context,
enableDrag: false,
barrierColor: Colors.black.withValues(alpha: 0.75),
builder: (innerContext) => SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 28, 22, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
'请阅读并同意以下条款',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 14),
Center(
child: Text.rich(
textAlign: TextAlign.center,
TextSpan(
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: theme.colorScheme.mutedForeground,
height: 1.7,
fontSize: 12,
),
children: [
const TextSpan(text: '登录前需要先阅读并同意 '),
TextSpan(
text: '《用户协议》',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: theme.colorScheme.primary,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
),
const TextSpan(text: ''),
TextSpan(
text: '《隐私政策》',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: theme.colorScheme.primary,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
),
],
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: AppButton(
label: '同意并发送验证码',
expand: true,
onPressed: () {
Navigator.of(innerContext).pop();
agreed.value = true;
onAgreed();
},
),
),
],
),
),
),
),
);
}
Future<void> onSendCodeTap() async {
if (sendingCode.value || countdown.value > 0) return;
if (!agreed.value) {
showPrivacyCheckDialog(() {
unawaited(sendCode());
});
return;
}
unawaited(sendCode());
}
final clickable = !sendingCode.value && !verifying.value;
return PopScope(
canPop: (next ?? '').isEmpty,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (!context.mounted) return;
final nextPath = next;
if (nextPath != null && nextPath.isNotEmpty) {
context.go(_backTargetFromNext());
return;
}
if (context.canPop()) {
context.pop();
return;
}
context.go(AppRoutes.profile);
},
child: Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: AppBar(
leading: IconButton(
icon: const Icon(AppIcons.arrowLeft),
onPressed: () {
final nextPath = next;
if (nextPath != null && nextPath.isNotEmpty) {
context.go(_backTargetFromNext());
return;
}
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.profile);
}
},
),
title: const Text('登录 · Login'),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
20,
),
children: [
const PageHeader(title: '登录研听', subtitle: '先把登录逻辑接通,弹窗入口保持不变'),
if (auth.loggedIn) ...[
AppCard(
color: theme.colorScheme.secondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前已登录', style: YantingText.cardTitle),
const SizedBox(height: 6),
Text(
auth.phone == null
? '本地登录态已生效'
: '手机号 ${maskPhone(auth.phone!)}',
style: YantingText.body.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
],
AppCard(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('手机号登录', style: YantingText.sectionTitle),
const SizedBox(height: 12),
ShadInput(
controller: phoneController,
placeholder: const Text('请输入手机号'),
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
const SizedBox(height: 12),
ShadInput(
controller: codeController,
focusNode: codeFocusNode,
placeholder: const Text('验证码'),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
trailing: Padding(
padding: const EdgeInsets.only(right: 4),
child: AppButton(
label: countdown.value > 0
? '${countdown.value}s'
: '发送验证码',
kind: AppButtonKind.ghost,
compact: true,
onPressed: onSendCodeTap,
),
),
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: ShadCheckbox(
value: agreed.value,
onChanged: (value) => agreed.value = value,
),
),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(
style: YantingText.meta.copyWith(
color: theme.colorScheme.mutedForeground,
height: 1.7,
),
children: [
const TextSpan(text: '已阅读并同意 '),
TextSpan(
text: '《用户协议》',
style: YantingText.meta.copyWith(
color: theme.colorScheme.primary,
),
),
const TextSpan(text: ''),
TextSpan(
text: '《隐私政策》',
style: YantingText.meta.copyWith(
color: theme.colorScheme.primary,
),
),
],
),
),
),
],
),
if (error.value != null) ...[
const SizedBox(height: 10),
Text(
error.value!,
style: YantingText.meta.copyWith(
color: theme.colorScheme.destructive,
),
),
],
const SizedBox(height: 18),
AppButton(
label: verifying.value ? '登录中...' : '登录',
expand: true,
onPressed: clickable
? () {
if (!agreed.value) {
showPrivacyCheckDialog(() {
unawaited(submitLogin());
});
return;
}
unawaited(submitLogin());
}
: null,
kind: AppButtonKind.primary,
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
AppCard(
color: theme.colorScheme.secondary,
child: Text(
codeSent.value
? '验证码逻辑已接通,当前先用本地登录态串起页面流转。'
: '当前版本先做本地登录态和页面流转,后端接口接入后再替换为真实校验。',
style: YantingText.meta.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
),
],
),
),
);
}
}
String maskPhone(String phone) {
if (phone.length < 7) return phone;
return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
}
-351
View File
@@ -1,351 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import 'modules/renderer_registry.dart';
class ReportDetailPage extends HookConsumerWidget {
const ReportDetailPage({
required this.reportId,
required this.dataSource,
this.player = const PlayerStateModel(),
this.onStartAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final String reportId;
final ReportDataSource dataSource;
final PlayerStateModel player;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final retryCount = useState(0);
final detailFuture = useMemoized(() => dataSource.reportDetail(reportId), [
dataSource,
reportId,
retryCount.value,
]);
final snapshot = useFuture(detailFuture);
const registry = ModuleRendererRegistry();
final theme = ShadTheme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: AppBar(
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('研报详情'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
),
body: snapshot.connectionState != ConnectionState.done
? const LoadingState()
: snapshot.hasError
? ErrorState(
message: snapshot.error.toString(),
onRetry: () => retryCount.value++,
)
: _ReportDetailContent(
detail: snapshot.data!,
dataSource: dataSource,
player: player,
onStartAudio: onStartAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
registry: registry,
),
);
}
}
class _ReportDetailContent extends StatelessWidget {
const _ReportDetailContent({
required this.detail,
required this.dataSource,
required this.player,
required this.registry,
this.onStartAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
});
final ReportDetail detail;
final ReportDataSource dataSource;
final PlayerStateModel player;
final ModuleRendererRegistry registry;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [
AppCard(
color: colors.brandSoft,
borderColor: colors.brandSoftBorder,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
AppBadge(
text: detail.interpretationLabel,
kind: BadgeKind.brand,
),
if (detail.hasAudio)
const AppBadge(
text: '音频',
icon: AppIcons.playCircle,
kind: BadgeKind.audio,
),
AppBadge(
text: asString(detail.source['source_tier']),
kind: BadgeKind.tier,
),
],
),
const SizedBox(height: YantingSpacing.x3),
Text(
detail.titleCn,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: YantingText.sectionTitle.copyWith(fontSize: 21),
),
if (detail.oneLiner.isNotEmpty) ...[
const SizedBox(height: YantingSpacing.x2),
Text(detail.oneLiner, style: YantingText.body),
],
const SizedBox(height: YantingSpacing.x3),
Text(
'${detail.institution.nameCn} · ${formatDate(detail.releasedAt)}',
style: YantingText.meta,
),
],
),
),
const SizedBox(height: YantingSpacing.x4),
_ActionBar(detail: detail),
const SizedBox(height: YantingSpacing.x4),
_Toc(modules: detail.modules),
const SizedBox(height: YantingSpacing.x4),
for (final module in detail.modules) ...[
registry.card(
context: context,
module: module,
report: detail,
dataSource: dataSource,
player: player,
onStartAudio: onStartAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
const SizedBox(height: YantingSpacing.x4),
],
],
);
}
}
class _ActionBar extends ConsumerWidget {
const _ActionBar({required this.detail});
final ReportDetail detail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authControllerProvider);
final profile = ref.watch(profileControllerProvider);
final isFavorite = profile.favorites.contains(detail.id);
final isSavedListen = profile.savedListens.contains(detail.id);
return Row(
children: [
Expanded(
child: AppButton(
label: isFavorite ? '已收藏' : '收藏',
icon: isFavorite ? AppIcons.heartFill : AppIcons.heart,
kind: AppButtonKind.ghost,
onPressed: () => _runLoginRequiredAction(
context,
ref,
auth,
PendingLoginAction(
action: LoginRequiredAction.favorite,
reportId: detail.id,
contextText: '登录后保存到你的收藏',
),
),
),
),
const SizedBox(width: YantingSpacing.x2),
if (detail.hasAudio) ...[
Expanded(
child: AppButton(
label: isSavedListen ? '已存听单' : '听单',
icon: isSavedListen
? AppIcons.headphonesFill
: AppIcons.headphones,
kind: AppButtonKind.ghost,
onPressed: () => _runLoginRequiredAction(
context,
ref,
auth,
PendingLoginAction(
action: LoginRequiredAction.saveListen,
reportId: detail.id,
contextText: '登录后保存到你的听单',
),
),
),
),
const SizedBox(width: YantingSpacing.x2),
],
Expanded(
child: AppButton(
label: '原文',
icon: AppIcons.externalLink,
kind: AppButtonKind.ghost,
onPressed: () => _showSourceSheet(context, ref),
),
),
],
);
}
void _runLoginRequiredAction(
BuildContext context,
WidgetRef ref,
AuthState auth,
PendingLoginAction action,
) {
if (auth.loggedIn) {
_applyPendingAction(ref, action);
return;
}
ref.read(authControllerProvider.notifier).requireLogin(action);
showLoginSheet(
context,
reason: action.contextText,
onPhoneLogin: () => _loginAndApply(ref, LoginMethod.phone),
onSecondaryLogin: () => _loginAndApply(ref, LoginMethod.wechat),
);
}
void _loginAndApply(WidgetRef ref, LoginMethod method) {
ref.read(authControllerProvider.notifier).login(method).then((pending) {
if (pending != null) {
_applyPendingAction(ref, pending);
}
});
}
void _applyPendingAction(WidgetRef ref, PendingLoginAction action) {
final controller = ref.read(profileControllerProvider.notifier);
switch (action.action) {
case LoginRequiredAction.favorite:
controller.toggleFavorite(action.reportId);
case LoginRequiredAction.saveListen:
controller.toggleSavedListen(action.reportId);
}
}
void _showSourceSheet(BuildContext context, WidgetRef ref) {
final targetUrl = asString(
detail.source['url'],
asString(
detail.source['source_url'],
asString(detail.source['original_url']),
),
);
showOutboundSheet(
context,
title: detail.titleCn,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(
OutboundEvent(
scene: 'report_source',
refId: detail.id,
targetUrl: targetUrl.isEmpty ? null : targetUrl,
),
),
);
}
}
class _Toc extends StatelessWidget {
const _Toc({required this.modules});
final List<DisplayModule> modules;
@override
Widget build(BuildContext context) {
if (modules.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final module in modules)
Padding(
padding: const EdgeInsets.only(right: YantingSpacing.x2),
child: AppBadge(text: module.titleCn, kind: BadgeKind.brand),
),
],
),
);
}
}
-156
View File
@@ -1,156 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../routing/app_routes.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class FeedPage extends HookConsumerWidget {
const FeedPage({
required this.dataSource,
required this.onPlay,
this.player = const PlayerStateModel(),
this.onStartModuleAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentTopic = ref.watch(recommendTopicProvider);
final snapshot = ref.watch(recommendedByTopicProvider);
const topics = ['全部', '宏观', '贵金属', '大宗', '能源', '跨资产', '央行'];
return snapshot.when(
loading: () => const LoadingState(),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(recommendedByTopicProvider),
),
data: (items) {
if (items.isEmpty) {
return EmptyState(
title: currentTopic == '全部' ? '暂无可推荐的研报解读' : '当前主题暂无内容',
message: currentTopic == '全部' ? '稍后再来看看最新内容' : '换个主题,或去研报页看看全部内容',
icon: currentTopic == '全部'
? Icons.inbox_outlined
: Icons.filter_alt_off,
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
children: [
const PageHeader(title: '研听', subtitle: '全球机构研报中文解读'),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final t in topics)
Padding(
padding: const EdgeInsets.only(right: YantingSpacing.x2),
child: AppChip(
label: t,
selected: t == currentTopic,
onTap: () =>
ref.read(recommendTopicProvider.notifier).select(t),
),
),
],
),
),
const SizedBox(height: YantingSpacing.cardGap),
ReportCardWidget(
report: items.first,
hero: true,
onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(items.first.id);
openReportDetail(
context,
dataSource,
items.first,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
);
},
onPlayTap: () => _playFromReport(onPlay, items.first),
),
const SizedBox(height: YantingSpacing.sectionGap),
const SectionTitle(title: '最新解读', icon: Icons.chevron_right),
for (final report in items.skip(1)) ...[
ReportCardWidget(
report: report,
onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(
context,
dataSource,
report,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
);
},
onPlayTap: () => _playFromReport(onPlay, report),
),
const SizedBox(height: YantingSpacing.x3),
],
],
);
},
);
}
}
void _playFromReport(
void Function(AudioItem item) onPlay,
ReportCardModel report,
) {
onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
);
}
-221
View File
@@ -1,221 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class HomePage extends HookConsumerWidget {
const HomePage({
required this.dataSource,
required this.onPlay,
this.player = const PlayerStateModel(),
this.onStartModuleAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final snapshot = ref.watch(recommendedReportsProvider);
return snapshot.when(
loading: () => const LoadingState(),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(recommendedReportsProvider),
),
data: (items) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const PageHeader(title: '研听', subtitle: '全球机构研报中文解读'),
const SectionTitle(title: '推荐'),
if (items.isEmpty)
const EmptyState(title: '暂无可推荐的研报解读', message: '稍后再来看看最新内容')
else
ReportCardWidget(
report: items.first,
hero: true,
onTap: () => openReportDetail(
context,
dataSource,
items.first,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
onPlayTap: () => _playFromReport(onPlay, items.first),
),
const SizedBox(height: YantingSpacing.x6),
for (final item in _directoryItems)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _DirectoryCard(item: item),
),
],
),
);
},
);
}
}
class _DirectoryItem {
const _DirectoryItem({
required this.title,
required this.subtitle,
required this.icon,
required this.path,
});
final String title;
final String subtitle;
final IconData icon;
final String path;
}
class _DirectoryCard extends StatelessWidget {
const _DirectoryCard({required this.item});
final _DirectoryItem item;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AppCard(
onTap: () => context.push(item.path),
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
item.icon,
size: 20,
color: theme.colorScheme.secondaryForeground,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(item.title, style: YantingText.listTitle),
),
if (item.title == '推荐') ...[
const SizedBox(width: 8),
const AppBadge(text: '首页', kind: BadgeKind.tier),
],
],
),
const SizedBox(height: 2),
Text(item.subtitle, style: YantingText.meta),
],
),
),
Icon(
LucideIcons.chevronRight,
size: 16,
color: theme.colorScheme.mutedForeground,
),
],
),
);
}
}
const _directoryItems = [
_DirectoryItem(
title: '推荐',
subtitle: '主题筛选后的重点研报解读',
icon: AppIcons.sparkle,
path: AppRoutes.home,
),
_DirectoryItem(
title: '研报',
subtitle: '搜索、筛选和浏览全部研报',
icon: AppIcons.article,
path: AppRoutes.reports,
),
_DirectoryItem(
title: '机构',
subtitle: '按机构查看来源与覆盖主题',
icon: AppIcons.bank,
path: AppRoutes.institutions,
),
_DirectoryItem(
title: '听单',
subtitle: '继续收听音频解读',
icon: AppIcons.headphones,
path: AppRoutes.listen,
),
_DirectoryItem(
title: '我的',
subtitle: '登录、收藏与合规说明',
icon: AppIcons.user,
path: AppRoutes.profile,
),
];
void _playFromReport(
void Function(AudioItem item) onPlay,
ReportCardModel report,
) {
onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
);
}
@@ -1,201 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import '../../widgets/institution_card.dart';
import '../shared/report_card_widget.dart';
class InstitutionDetailPage extends HookConsumerWidget {
const InstitutionDetailPage({
required this.institutionId,
required this.dataSource,
super.key,
});
final String institutionId;
final ReportDataSource dataSource;
@override
Widget build(BuildContext context, WidgetRef ref) {
final retryCount = useState(0);
final future = useMemoized(
() => dataSource.institutionDetail(institutionId),
[dataSource, institutionId, retryCount.value],
);
final snapshot = useFuture(future);
final theme = ShadTheme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: AppBar(
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('机构主页'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: ColoredBox(
color: theme.colorScheme.border,
child: const SizedBox(height: 1, width: double.infinity),
),
),
),
body: snapshot.connectionState != ConnectionState.done
? const LoadingState()
: snapshot.hasError
? ErrorState(
message: snapshot.error.toString(),
onRetry: () => retryCount.value++,
)
: _InstitutionDetailContent(
item: snapshot.data!,
dataSource: dataSource,
),
);
}
}
class _InstitutionDetailContent extends ConsumerWidget {
const _InstitutionDetailContent({
required this.item,
required this.dataSource,
});
final Institution item;
final ReportDataSource dataSource;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [
AppCard(
color: YantingColors.brandSoft,
borderColor: YantingColors.brandSoftBorder,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
InstitutionLogo(
logoUrl: item.logoUrl,
initials: item.nameCn.isEmpty
? ''
: item.nameCn.characters.take(2).toString(),
size: 48,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.nameCn,
style: YantingText.sectionTitle.copyWith(
fontSize: 21,
),
),
if (item.nameEn.isNotEmpty)
Text(item.nameEn, style: YantingText.meta),
],
),
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
AppBadge(text: item.sourceTier, kind: BadgeKind.tier),
AppBadge(
text: '${item.reportCount} 份研报',
kind: BadgeKind.brand,
),
for (final topic in item.coveredTopics) AppBadge(text: topic),
],
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
if (item.introCn.isNotEmpty)
AppCard(child: Text(item.introCn, style: YantingText.body)),
const SizedBox(height: YantingSpacing.x3),
if (item.credibilityNote.isNotEmpty)
AppCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(AppIcons.shield, color: YantingColors.chart2),
const SizedBox(width: YantingSpacing.x2),
Expanded(
child: Text(item.credibilityNote, style: YantingText.body),
),
],
),
),
const SizedBox(height: YantingSpacing.x6),
Text('最新研报', style: YantingText.sectionTitle.copyWith(fontSize: 21)),
const SizedBox(height: YantingSpacing.x3),
if (item.recentReports.isEmpty)
const EmptyState(
title: '机构暂无研报',
message: '稍后再试',
icon: Icons.article_outlined,
)
else
for (final report in item.recentReports) ...[
ReportCardWidget(
report: report,
onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(context, dataSource, report);
},
),
const SizedBox(height: YantingSpacing.x3),
],
AppButton(
label: '了解相关服务',
icon: AppIcons.externalLink,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () => showOutboundSheet(
context,
title: item.nameCn,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(
OutboundEvent(
scene: 'institution_service',
refId: item.id,
targetUrl: item.websiteUrl.isEmpty ? null : item.websiteUrl,
),
),
),
),
],
);
}
}
@@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../routing/app_routes.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/institution_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
class InstitutionsPage extends HookConsumerWidget {
const InstitutionsPage({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
Widget build(BuildContext context, WidgetRef ref) {
final snapshot = ref.watch(institutionsProvider);
return snapshot.when(
loading: () => const LoadingState(),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(institutionsProvider),
),
data: (items) {
final sorted = [...items]
..sort((a, b) => b.reportCount.compareTo(a.reportCount));
if (sorted.isEmpty) {
return const EmptyState(
title: '暂无机构信息',
message: '稍后再试',
icon: Icons.account_balance_outlined,
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
children: [
const PageHeader(title: '机构', subtitle: '可获取研报的机构'),
const SizedBox(height: YantingSpacing.cardGap),
for (final item in sorted) ...[
InstitutionCard(
institution: item,
onTap: () =>
openInstitutionDetail(context, dataSource, item.id),
),
const SizedBox(height: YantingSpacing.x3),
],
],
);
},
);
}
}
-243
View File
@@ -1,243 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
class ListenPage extends HookConsumerWidget {
const ListenPage({required this.dataSource, required this.onPlay, super.key});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
@override
Widget build(BuildContext context, WidgetRef ref) {
final snapshot = ref.watch(listenProvider);
return snapshot.when(
loading: () => const LoadingState(label: '正在加载听单'),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(listenProvider),
),
data: (items) {
if (items.isEmpty) {
return const EmptyState(
title: '暂无音频研报',
message: '先去研报页看看图文解读',
icon: Icons.headphones_outlined,
);
}
final current = items.first;
return ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
children: [
const PageHeader(title: '听单', subtitle: '已转音频的研报解读'),
const SectionTitle(title: '继续收听'),
_ContinueListeningCard(
item: current,
onPlay: () => _playAndRemember(ref, current),
),
const SectionTitle(title: '全部音频', icon: AppIcons.arrowRight),
const SizedBox(height: YantingSpacing.cardGap),
for (final item in items.skip(1)) ...[
_AudioListCard(
item: item,
onPlay: () => _playAndRemember(ref, item),
),
const SizedBox(height: YantingSpacing.x3),
],
],
);
},
);
}
void _playAndRemember(WidgetRef ref, AudioItem item) {
ref.read(profileControllerProvider.notifier).addHistory(item.reportId);
onPlay(item);
}
}
class _ContinueListeningCard extends StatelessWidget {
const _ContinueListeningCard({required this.item, required this.onPlay});
final AudioItem item;
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
onTap: onPlay,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Wrap(
spacing: 8,
runSpacing: 8,
children: [
AppBadge(text: '研报解读', kind: BadgeKind.brand),
AppBadge(text: '音频', kind: BadgeKind.audio),
],
),
const SizedBox(height: 14),
Text(
item.reportTitleCn,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: YantingText.cardTitle,
),
const SizedBox(height: 14),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
item.institution.nameCn,
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w500,
),
),
Text('·', style: YantingText.meta),
Text(
'全长 ${formatDuration(item.durationSec)}',
style: YantingText.meta,
),
],
),
const SizedBox(height: 16),
Row(
children: [
_PlayControlButton(onPressed: onPlay, size: 54, iconSize: 22),
const SizedBox(width: 13),
Expanded(
child: Column(
children: [
const ShadProgress(value: 0.42),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'06:01',
style: YantingText.meta.copyWith(fontSize: 12),
),
Text(
'-08:19',
style: YantingText.meta.copyWith(fontSize: 12),
),
],
),
],
),
),
],
),
],
),
);
}
}
class _AudioListCard extends StatelessWidget {
const _AudioListCard({required this.item, required this.onPlay});
final AudioItem item;
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
padding: const EdgeInsets.all(14),
onTap: onPlay,
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.xl),
),
child: Icon(
AppIcons.music,
color: colors.mutedForeground,
size: 24,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.reportTitleCn,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: YantingText.listTitle,
),
const SizedBox(height: 6),
Text(
'${item.institution.nameCn} · ${formatDuration(item.durationSec)}',
style: YantingText.meta,
),
],
),
),
const SizedBox(width: 10),
_PlayControlButton(onPressed: onPlay, size: 44, iconSize: 18),
],
),
);
}
}
class _PlayControlButton extends StatelessWidget {
const _PlayControlButton({
required this.onPressed,
required this.size,
required this.iconSize,
});
final VoidCallback onPressed;
final double size;
final double iconSize;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Material(
color: colors.primary,
borderRadius: BorderRadius.circular(YantingRadius.pill),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(YantingRadius.pill),
child: SizedBox.square(
dimension: size,
child: Icon(
AppIcons.play,
color: colors.primaryForeground,
size: iconSize,
),
),
),
);
}
}
-554
View File
@@ -1,554 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({required this.dataSource, super.key});
final ReportDataSource dataSource;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ShadTheme.of(context).colorScheme;
final auth = ref.watch(authControllerProvider);
final profile = ref.watch(profileControllerProvider);
final historySnapshot = ref.watch(profileHistoryReportsProvider);
final favoriteSnapshot = ref.watch(profileFavoriteReportsProvider);
final savedListenSnapshot = ref.watch(profileSavedListenReportsProvider);
final historyCount = historySnapshot.maybeWhen(
data: (items) => items.length,
orElse: () => profile.history.length,
);
final favoriteCount = favoriteSnapshot.maybeWhen(
data: (items) => items.length,
orElse: () => profile.favorites.length,
);
final savedListenCount = savedListenSnapshot.maybeWhen(
data: (items) => items.length,
orElse: () => profile.savedListens.length,
);
return ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
children: [
const PageHeader(title: '我的'),
if (auth.loggedIn) ...[
_LoggedInHeader(auth: auth),
const SizedBox(height: YantingSpacing.x3),
_StatsCard(
favoriteCount: favoriteCount,
historyCount: historyCount,
savedListenCount: savedListenCount,
),
] else ...[
AppCard(
color: colors.secondary,
child: Row(
children: [
CircleAvatar(
radius: 27,
backgroundColor: colors.background,
foregroundColor: colors.mutedForeground,
child: const Icon(AppIcons.user, size: 28),
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'未登录',
style: YantingText.cardTitle.copyWith(
fontSize: 18,
color: colors.foreground,
),
),
const SizedBox(height: 5),
Text(
'登录后同步收藏、历史和听单',
style: YantingText.meta.copyWith(
height: 1.5,
color: colors.mutedForeground,
),
),
],
),
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
AppButton(
label: '登录 / 注册',
expand: true,
onPressed: () => context.push(
'${AppRoutes.login}?next=${Uri.encodeComponent(AppRoutes.profile)}',
),
),
],
const SizedBox(height: 18),
_MenuGroup(
children: [
_MenuRow(
icon: AppIcons.heart,
title: '我的收藏',
trailing: '$favoriteCount',
onTap: () => _showLoginAwareList(
context,
ref,
auth,
title: '我的收藏',
snapshot: favoriteSnapshot,
emptyTitle: '暂无收藏',
emptyMessage: '在研报详情页点击收藏后会出现在这里',
),
),
_MenuRow(
icon: AppIcons.headphones,
title: '保存的听单',
trailing: '$savedListenCount',
onTap: () => _showLoginAwareList(
context,
ref,
auth,
title: '保存的听单',
snapshot: savedListenSnapshot,
emptyTitle: '暂无保存的听单',
emptyMessage: '在音频研报详情页保存听单后会出现在这里',
),
),
_MenuRow(
icon: AppIcons.history,
title: '浏览/收听历史',
trailing: '$historyCount',
onTap: () => _showProfileListSheet(
context,
ref,
title: '浏览/收听历史',
snapshot: historySnapshot,
emptyTitle: '暂无浏览/收听历史',
emptyMessage: '打开研报详情或播放音频后会出现在这里',
),
),
_MenuRow(
icon: Icons.download_outlined,
title: '下载记录',
trailing: 'Phase 1 预留',
onTap: () => showAppToast(context, '下载记录将在后续版本接入'),
),
],
),
const SizedBox(height: YantingSpacing.x3),
_MenuGroup(
children: [
_MenuRow(
icon: AppIcons.settings,
title: '设置',
onTap: () => context.push(AppRoutes.settings),
),
if (auth.loggedIn)
_MenuRow(
icon: Icons.logout,
title: '退出登录',
onTap: () => ref.read(authControllerProvider.notifier).logout(),
),
],
),
const SizedBox(height: YantingSpacing.x3),
AppCard(
color: colors.secondary,
onTap: () => _showOutbound(context, ref, 'profile_services', '相关服务'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'了解相关服务',
style: YantingText.body.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 3),
const Icon(AppIcons.arrowRight, size: 18),
],
),
const SizedBox(height: 6),
Text(
'与你关注主题相关的延伸服务,内容不构成投资建议。',
style: YantingText.meta.copyWith(
height: 1.5,
color: colors.mutedForeground,
),
),
],
),
),
const SizedBox(height: 22),
Text(
'研听 · 全球机构研报中文解读\n登录不阻断游客完整收听第一期 · 内容不构成投资建议',
textAlign: TextAlign.center,
style: YantingText.meta.copyWith(fontSize: 12, height: 1.7),
),
],
);
}
Future<void> _login(WidgetRef ref, LoginMethod method) async {
await ref.read(authControllerProvider.notifier).login(method);
await ref.read(profileControllerProvider.notifier).refresh();
}
void _showOutbound(
BuildContext context,
WidgetRef ref,
String scene,
String title,
) {
showOutboundSheet(
context,
title: title,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(OutboundEvent(scene: scene)),
);
}
void _showLoginAwareList(
BuildContext context,
WidgetRef ref,
AuthState auth, {
required String title,
required AsyncValue<List<ReportCardModel>> snapshot,
required String emptyTitle,
required String emptyMessage,
}) {
if (!auth.loggedIn) {
showLoginSheet(
context,
reason: '登录后查看$title',
onPhoneLogin: () => _login(ref, LoginMethod.phone),
onSecondaryLogin: () => _login(ref, LoginMethod.wechat),
);
return;
}
_showProfileListSheet(
context,
ref,
title: title,
snapshot: snapshot,
emptyTitle: emptyTitle,
emptyMessage: emptyMessage,
);
}
void _showProfileListSheet(
BuildContext context,
WidgetRef ref, {
required String title,
required AsyncValue<List<ReportCardModel>> snapshot,
required String emptyTitle,
required String emptyMessage,
}) {
showShadSheet<void>(
context: context,
side: ShadSheetSide.bottom,
builder: (sheetContext) => ShadSheet(
title: Text(title),
description: const Text('本地状态列表,真实同步接口后续接入。'),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.66,
),
child: snapshot.when(
loading: () => const LoadingState(label: '正在加载列表'),
error: (error, _) => ErrorState(message: error.toString()),
data: (items) {
if (items.isEmpty) {
return EmptyState(
title: emptyTitle,
message: emptyMessage,
icon: Icons.inbox_outlined,
);
}
return ListView.separated(
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, _) =>
const SizedBox(height: YantingSpacing.x3),
itemBuilder: (_, index) {
final report = items[index];
return ReportCardWidget(
report: report,
onTap: () {
Navigator.pop(sheetContext);
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(context, dataSource, report);
},
);
},
);
},
),
),
),
);
}
}
class _LoggedInHeader extends StatelessWidget {
const _LoggedInHeader({required this.auth});
final AuthState auth;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final phone = auth.phone;
final methodLabel = switch (auth.loginMethod) {
LoginMethod.phone => '手机号',
LoginMethod.wechat => '微信',
LoginMethod.apple => 'Apple',
_ => '本地登录',
};
return AppCard(
color: colors.brandSoft,
borderColor: colors.brandSoftBorder,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 30),
child: Row(
children: [
CircleAvatar(
radius: 34,
backgroundColor: colors.background,
foregroundColor: colors.primary,
child: Text(
phone == null || phone.isEmpty ? '' : phone.characters.first,
style: YantingText.sectionTitle.copyWith(
color: colors.primary,
fontSize: 27,
),
),
),
const SizedBox(width: 22),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
phone == null || phone.isEmpty ? '研界用户' : '手机号用户',
style: YantingText.sectionTitle.copyWith(
color: colors.foreground,
fontSize: 22,
),
),
const SizedBox(height: 4),
Text(
'已登录 · $methodLabel${phone == null || phone.isEmpty ? '' : ' · ${_maskPhone(phone)}'}',
style: YantingText.body.copyWith(
color: colors.mutedForeground,
fontSize: 15,
),
),
],
),
),
],
),
);
}
}
String _maskPhone(String phone) {
if (phone.length < 7) return phone;
return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
}
class _StatsCard extends StatelessWidget {
const _StatsCard({
required this.favoriteCount,
required this.historyCount,
required this.savedListenCount,
});
final int favoriteCount;
final int historyCount;
final int savedListenCount;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
padding: EdgeInsets.zero,
child: Row(
children: [
Expanded(
child: _StatCell(value: favoriteCount, label: '收藏'),
),
SizedBox(
height: 66,
child: VerticalDivider(
width: 1,
thickness: 1,
color: colors.border,
),
),
Expanded(
child: _StatCell(value: historyCount, label: '历史'),
),
SizedBox(
height: 66,
child: VerticalDivider(
width: 1,
thickness: 1,
color: colors.border,
),
),
Expanded(
child: _StatCell(value: savedListenCount, label: '听单'),
),
],
),
);
}
}
class _StatCell extends StatelessWidget {
const _StatCell({required this.value, required this.label});
final int value;
final String label;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Column(
children: [
Text(
'$value',
style: YantingText.sectionTitle.copyWith(
color: const Color(0xFF163E08),
fontSize: 22,
fontFeatures: YantingTypographyFeatures.tabularNums,
),
),
const SizedBox(height: 3),
Text(
label,
style: YantingText.meta.copyWith(
color: colors.mutedForeground,
fontSize: 14,
),
),
],
),
);
}
}
class _MenuGroup extends StatelessWidget {
const _MenuGroup({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
if (index != children.length - 1)
Divider(height: 1, thickness: 1, color: colors.border),
],
],
),
);
}
}
class _MenuRow extends StatelessWidget {
const _MenuRow({
required this.icon,
required this.title,
required this.onTap,
this.trailing,
});
final IconData icon;
final String title;
final String? trailing;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF143B05)),
const SizedBox(width: 13),
Expanded(child: Text(title, style: YantingText.body)),
if (trailing != null)
DecoratedBox(
decoration: BoxDecoration(
color: colors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.pill),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 3,
),
child: Text(
trailing!,
style: YantingText.meta.copyWith(
fontSize: 11.5,
color: colors.secondaryForeground,
),
),
),
)
else
Icon(
AppIcons.arrowRight,
color: colors.mutedForeground,
size: 20,
),
],
),
),
);
}
}
-422
View File
@@ -1,422 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/api/report_data_source.dart';
import '../../data/content_providers.dart';
import '../../data/models/models.dart';
import '../../data/providers.dart';
import '../../data/state/report_query.dart';
import '../../routing/app_routes.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/mini_player.dart';
import '../../widgets/page_header.dart';
import '../../widgets/states.dart';
import '../shared/report_card_widget.dart';
class ReportsPage extends HookConsumerWidget {
const ReportsPage({
required this.dataSource,
required this.onPlay,
this.player = const PlayerStateModel(),
this.onStartModuleAudio,
this.onToggleAudio,
this.onSeekAudio,
this.onSpeed,
super.key,
});
final ReportDataSource dataSource;
final void Function(AudioItem item) onPlay;
final PlayerStateModel player;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartModuleAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ShadTheme.of(context);
final searchController = useTextEditingController();
final query = ref.watch(reportFilterProvider);
final snapshot = ref.watch(reportsProvider);
final institutionsSnapshot = ref.watch(institutionsProvider);
final controller = ref.read(reportFilterProvider.notifier);
final institutions = institutionsSnapshot.maybeWhen(
data: (items) => items,
orElse: () => const <Institution>[],
);
return snapshot.when(
loading: () => const LoadingState(label: '正在加载研报'),
error: (error, _) => ErrorState(
message: error.toString(),
onRetry: () => ref.invalidate(reportsProvider),
),
data: (allReports) {
final items = _applyReportQuery(allReports, query);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
16,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const PageHeader(title: '研报', subtitle: '全部已发布研报解读'),
ShadInput(
controller: searchController,
placeholder: const Text('搜索标题、机构或主题'),
leading: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(LucideIcons.search, size: 16),
),
trailing: query.search.isEmpty
? null
: Padding(
padding: const EdgeInsets.only(left: 8),
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () {
searchController.clear();
controller.setSearch('');
},
child: const Icon(LucideIcons.x, size: 16),
),
),
onChanged: (value) => controller.setSearch(value.trim()),
),
const SizedBox(height: YantingSpacing.cardGap),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
ShadButton.outline(
onPressed: allReports.isEmpty
? null
: () => _openFilterSheet(
context,
items: allReports,
institutions: institutions,
),
leading: const Icon(
LucideIcons.slidersHorizontal,
size: 16,
),
child: Text(query.hasActiveFilter ? '筛选中' : '筛选'),
),
ShadButton.outline(
onPressed: () => controller.setSort(
query.sort == ReportSort.latest
? ReportSort.oldest
: ReportSort.latest,
),
leading: const Icon(
LucideIcons.arrowUpDown,
size: 16,
),
child: Text(
query.sort == ReportSort.latest ? '最新' : '最早',
),
),
ShadBadge.secondary(
onPressed: controller.toggleAudio,
backgroundColor: query.hasAudio
? theme.colorScheme.foreground
: theme.colorScheme.secondary,
foregroundColor: query.hasAudio
? theme.colorScheme.background
: theme.colorScheme.secondaryForeground,
hoverBackgroundColor: query.hasAudio
? theme.colorScheme.foreground.withValues(
alpha: 0.9,
)
: theme.colorScheme.border,
child: const Text('音频'),
),
],
),
),
const SizedBox(width: YantingSpacing.x2),
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text('${items.length}', style: YantingText.meta),
),
],
),
const SizedBox(height: YantingSpacing.cardGap),
if (items.isEmpty)
EmptyState(
title: query.search.isNotEmpty ? '未找到相关研报' : '当前筛选下暂无研报',
message: query.search.isNotEmpty ? '换个关键词试试' : '调整筛选条件后再试',
actionLabel: '清除筛选',
onAction: () {
searchController.clear();
controller.reset();
},
)
else
for (final report in items) ...[
ReportCardWidget(
report: report,
onTap: () {
ref
.read(profileControllerProvider.notifier)
.addHistory(report.id);
openReportDetail(
context,
dataSource,
report,
player: player,
onStartAudio: onStartModuleAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
);
},
onPlayTap: () => onPlay(
AudioItem(
audioId: 'local_${report.id}',
reportId: report.id,
titleCn: report.titleCn,
reportTitleCn: report.titleCn,
durationSec: 180,
institution: report.institution,
),
),
),
const SizedBox(height: YantingSpacing.x3),
],
],
),
);
},
);
}
}
List<ReportCardModel> _applyReportQuery(
List<ReportCardModel> items,
ReportQuery query,
) {
final search = query.search.trim().toLowerCase();
final filtered = items.where((item) {
final haystack =
'${item.titleCn} ${item.subtitleCn} ${item.oneLiner} '
'${item.institution.nameCn} ${item.institution.nameEn} '
'${item.topics.join(' ')}'
.toLowerCase();
if (search.isNotEmpty && !haystack.contains(search)) {
return false;
}
if (query.topic != null && !item.topics.contains(query.topic)) {
return false;
}
if (query.institutionId != null &&
item.institution.id != query.institutionId) {
return false;
}
if (query.hasAudio && !item.hasAudio) {
return false;
}
return true;
}).toList();
filtered.sort((a, b) {
final result = (b.releasedAt ?? '').compareTo(a.releasedAt ?? '');
return query.sort == ReportSort.oldest ? -result : result;
});
return filtered;
}
void _openFilterSheet(
BuildContext context, {
required List<ReportCardModel> items,
required List<Institution> institutions,
}) {
const demoTopics = ['宏观', '贵金属', '大宗', '能源', '跨资产', '央行'];
final dynamicTopics = {for (final item in items) ...item.topics};
final topics = [
...demoTopics,
...dynamicTopics.where((topic) => !demoTopics.contains(topic)),
];
final orderedInstitutions = [...institutions]
..sort((a, b) => b.reportCount.compareTo(a.reportCount));
showShadSheet<void>(
context: context,
side: ShadSheetSide.bottom,
builder: (context) {
return Consumer(
builder: (context, ref, _) {
final theme = ShadTheme.of(context);
final query = ref.watch(reportFilterProvider);
final controller = ref.read(reportFilterProvider.notifier);
final selectedBackground = theme.colorScheme.foreground;
final selectedForeground = theme.colorScheme.background;
final unselectedBackground = theme.colorScheme.secondary;
final unselectedForeground = theme.colorScheme.secondaryForeground;
ShadBadge option({
required String label,
required bool selected,
required VoidCallback onPressed,
}) {
return ShadBadge.secondary(
onPressed: onPressed,
backgroundColor: selected
? selectedBackground
: unselectedBackground,
foregroundColor: selected
? selectedForeground
: unselectedForeground,
hoverBackgroundColor: selected
? selectedBackground.withValues(alpha: 0.9)
: theme.colorScheme.border,
child: Text(label),
);
}
return ShadSheet(
title: const Text('筛选研报'),
description: const Text('按主题、机构和音频状态收窄列表。'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _FilterGroupTitle('主题'),
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
option(
label: '全部主题',
selected: query.topic == null,
onPressed: () => controller.setTopic(null),
),
for (final topic in topics)
option(
label: topic,
selected: query.topic == topic,
onPressed: () => controller.setTopic(
query.topic == topic ? null : topic,
),
),
],
),
const SizedBox(height: YantingSpacing.x4),
const _FilterGroupTitle('机构'),
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
option(
label: '全部机构',
selected: query.institutionId == null,
onPressed: () => controller.setInstitution(null),
),
for (final institution in orderedInstitutions.take(8))
option(
label: institution.nameCn,
selected: query.institutionId == institution.id,
onPressed: () => controller.setInstitution(
query.institutionId == institution.id
? null
: institution.id,
),
),
],
),
const SizedBox(height: YantingSpacing.x4),
const _FilterGroupTitle('音频'),
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
option(
label: '不限',
selected: !query.hasAudio,
onPressed: () {
if (query.hasAudio) controller.toggleAudio();
},
),
option(
label: '只看音频',
selected: query.hasAudio,
onPressed: () {
if (!query.hasAudio) controller.toggleAudio();
},
),
],
),
const SizedBox(height: YantingSpacing.x4),
const _FilterGroupTitle('排序'),
Wrap(
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
option(
label: '最新发布',
selected: query.sort == ReportSort.latest,
onPressed: () => controller.setSort(ReportSort.latest),
),
option(
label: '最早发布',
selected: query.sort == ReportSort.oldest,
onPressed: () => controller.setSort(ReportSort.oldest),
),
],
),
const SizedBox(height: YantingSpacing.x4),
Row(
children: [
Expanded(
child: ShadButton.outline(
onPressed: controller.reset,
child: const Text('重置'),
),
),
const SizedBox(width: YantingSpacing.x2),
Expanded(
child: ShadButton(
onPressed: () => Navigator.pop(context),
child: const Text('查看结果'),
),
),
],
),
],
),
);
},
);
},
);
}
class _FilterGroupTitle extends StatelessWidget {
const _FilterGroupTitle(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: YantingSpacing.x2),
child: Text(label, style: YantingText.sectionTitle),
);
}
}
-370
View File
@@ -1,370 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/providers.dart';
import '../../data/state/app_interaction_state.dart';
import '../../routing/app_routes.dart';
import '../../theme/app_icons.dart';
import '../../theme/theme_controller.dart';
import '../../theme/yanting_text.dart';
import '../../theme/yanting_tokens.dart';
import '../../widgets/app_card.dart';
import '../../widgets/page_header.dart';
import '../../widgets/sheets.dart';
import '../../widgets/states.dart';
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
final scheme = ShadTheme.of(context).colorScheme;
final auth = ref.watch(authControllerProvider);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(AppIcons.arrowLeft),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.home);
}
},
),
title: const Text('设置 · Settings'),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(
YantingSpacing.screenX,
4,
YantingSpacing.screenX,
20,
),
children: [
const PageHeader(title: '设置', subtitle: '统一外观、主题和常用入口'),
Text('外观', style: YantingText.sectionTitle),
const SizedBox(height: 12),
AppCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_ThemeModeTile(
label: '跟随系统',
description: '按系统深浅色自动切换',
selected: themeMode == ThemeMode.system,
onTap: () => ref
.read(themeModeProvider.notifier)
.setMode(ThemeMode.system),
),
const Divider(height: 1, thickness: 1),
_ThemeModeTile(
label: '浅色',
description: '稳定的浅色展示模式',
selected: themeMode == ThemeMode.light,
onTap: () => ref
.read(themeModeProvider.notifier)
.setMode(ThemeMode.light),
),
const Divider(height: 1, thickness: 1),
_ThemeModeTile(
label: '深色',
description: '适合低光环境阅读',
selected: themeMode == ThemeMode.dark,
onTap: () => ref
.read(themeModeProvider.notifier)
.setMode(ThemeMode.dark),
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
Text('入口', style: YantingText.sectionTitle),
const SizedBox(height: 12),
AppCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_LinkTile(
icon: Icons.description_outlined,
title: '用户协议',
onTap: () => _showOutbound(
context,
ref,
'settings_user_agreement',
'用户协议',
),
),
const Divider(height: 1, thickness: 1),
_LinkTile(
icon: Icons.privacy_tip_outlined,
title: '隐私政策',
onTap: () => _showOutbound(
context,
ref,
'settings_privacy_policy',
'隐私政策',
),
),
],
),
),
const SizedBox(height: YantingSpacing.x3),
Text('关于', style: YantingText.sectionTitle),
const SizedBox(height: 12),
AppCard(
color: scheme.secondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('研听', style: YantingText.cardTitle),
const SizedBox(height: 6),
Text(
'全球机构研报中文解读',
style: YantingText.meta.copyWith(fontSize: 12.5),
),
const SizedBox(height: 12),
Text(
'主题、按钮、卡片和间距统一到 demo 的展示层基线。',
style: YantingText.body.copyWith(fontSize: 14),
),
const SizedBox(height: 14),
Text(
'当前版本以本地构建信息为准,发布时再注入正式版本号。',
style: YantingText.meta.copyWith(fontSize: 12),
),
],
),
),
if (auth.loggedIn) ...[
const SizedBox(height: YantingSpacing.x3),
Text('账户', style: YantingText.sectionTitle),
const SizedBox(height: 12),
AppCard(
padding: EdgeInsets.zero,
child: _ActionTile(
icon: Icons.logout,
title: '退出登录',
subtitle: '退出后本地登录态会清空',
destructive: true,
onTap: () => _confirmLogout(context, ref),
),
),
],
],
),
);
}
void _showOutbound(
BuildContext context,
WidgetRef ref,
String scene,
String title,
) {
showOutboundSheet(
context,
title: title,
onConfirm: () => ref
.read(outboundRepositoryProvider)
.recordOutbound(OutboundEvent(scene: scene)),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final ok = await showShadDialog<bool>(
context: context,
variant: ShadDialogVariant.alert,
builder: (dialogContext) => ShadDialog.alert(
title: const Text('退出登录'),
description: const Text('退出后,本设备的登录态会清空,再次登录可继续使用。'),
actions: [
ShadButton.outline(
child: const Text('取消'),
onPressed: () => Navigator.of(dialogContext).pop(false),
),
ShadButton(
child: const Text('确定退出'),
onPressed: () => Navigator.of(dialogContext).pop(true),
),
],
),
);
if (ok != true) return;
await ref.read(authControllerProvider.notifier).logout();
await ref.read(profileControllerProvider.notifier).refresh();
if (!context.mounted) return;
showAppToast(context, '已退出登录');
context.go(AppRoutes.profile);
}
}
class _ThemeModeTile extends StatelessWidget {
const _ThemeModeTile({
required this.label,
required this.description,
required this.selected,
required this.onTap,
});
final String label;
final String description;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = ShadTheme.of(context).colorScheme;
final foreground = selected ? scheme.background : scheme.foreground;
final muted = scheme.mutedForeground;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: YantingText.body.copyWith(
color: scheme.foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
description,
style: YantingText.meta.copyWith(
color: muted,
fontSize: 12.5,
),
),
],
),
),
const SizedBox(width: 12),
DecoratedBox(
decoration: BoxDecoration(
color: selected ? scheme.foreground : scheme.secondary,
borderRadius: BorderRadius.circular(YantingRadius.pill),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
child: Icon(
selected ? Icons.check : Icons.radio_button_unchecked,
size: 16,
color: foreground,
),
),
),
],
),
),
);
}
}
class _LinkTile extends StatelessWidget {
const _LinkTile({
required this.icon,
required this.title,
required this.onTap,
});
final IconData icon;
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(
icon,
size: 20,
color: ShadTheme.of(context).colorScheme.foreground,
),
const SizedBox(width: 13),
Expanded(
child: Text(
title,
style: YantingText.body.copyWith(
color: ShadTheme.of(context).colorScheme.foreground,
),
),
),
const Icon(AppIcons.arrowRight, size: 18),
],
),
),
);
}
}
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.destructive = false,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final bool destructive;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final titleColor = destructive ? colors.destructive : colors.foreground;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(icon, size: 20, color: titleColor),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: YantingText.body.copyWith(
color: titleColor,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: YantingText.meta.copyWith(
color: colors.mutedForeground,
fontSize: 12.5,
),
),
],
),
),
const Icon(AppIcons.arrowRight, size: 18),
],
),
),
);
}
}
-136
View File
@@ -1,136 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../data/models/models.dart';
import '../../theme/app_icons.dart';
import '../../theme/yanting_text.dart';
import '../../theme/wise_tokens.dart';
import '../../widgets/app_buttons.dart';
import '../../widgets/app_card.dart';
import '../../widgets/badges.dart';
class ReportCardWidget extends StatelessWidget {
const ReportCardWidget({
required this.report,
required this.onTap,
this.hero = false,
this.onInstitutionTap,
this.onPlayTap,
super.key,
});
final ReportCardModel report;
final VoidCallback onTap;
final bool hero;
final VoidCallback? onInstitutionTap;
final VoidCallback? onPlayTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: hero ? WiseSpacing.x2 : 7,
runSpacing: hero ? WiseSpacing.x2 : 7,
children: [
AppBadge(text: report.interpretationLabel, kind: BadgeKind.brand),
if (report.hasAudio)
const AppBadge(
text: '音频',
icon: AppIcons.play,
kind: BadgeKind.audio,
),
if (report.sourceTier.isNotEmpty)
AppBadge(text: report.sourceTier, kind: BadgeKind.tier),
for (final topic in report.topics.take(3)) AppBadge(text: topic),
],
),
SizedBox(height: hero ? WiseSpacing.x3 : 10),
Text(
report.titleCn,
maxLines: hero ? 3 : 2,
overflow: TextOverflow.ellipsis,
style: hero
? YantingText.sectionTitle.copyWith(fontSize: 21, height: 1.4)
: YantingText.listTitle.copyWith(
fontSize: 17.5,
height: 1.38,
fontWeight: FontWeight.w700,
),
),
if (report.oneLiner.isNotEmpty) ...[
SizedBox(height: hero ? WiseSpacing.x2 : 7),
Text(
report.oneLiner,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: YantingText.body.copyWith(
color: colors.mutedForeground,
fontSize: hero ? null : 14,
),
),
],
SizedBox(height: hero ? WiseSpacing.x3 : 10),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
InkWell(
onTap: onInstitutionTap,
child: Text(
report.institution.nameCn,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w500,
),
),
),
if (report.releasedAt != null) ...[
const _MetaDot(),
Text(formatDate(report.releasedAt), style: YantingText.meta),
],
],
),
if (report.hasAudio) ...[
const SizedBox(height: 14),
AppButton(
label: '听研报',
icon: AppIcons.play,
kind: hero ? AppButtonKind.primary : AppButtonKind.accent,
compact: !hero,
onPressed: onPlayTap,
),
],
],
);
return hero
? HeroReportCard(onTap: onTap, child: child)
: AppCard(
onTap: onTap,
padding: const EdgeInsets.all(16),
child: child,
);
}
}
class _MetaDot extends StatelessWidget {
const _MetaDot();
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Container(
width: 3,
height: 3,
decoration: BoxDecoration(
color: colors.mutedForeground,
shape: BoxShape.circle,
),
);
}
}
-156
View File
@@ -1,156 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/providers.dart';
import '../routing/app_routes.dart';
import '../theme/yanting_text.dart';
import '../widgets/bottom_tab_bar.dart';
import '../widgets/mini_player.dart';
class ShellPage extends ConsumerStatefulWidget {
const ShellPage({required this.child, required this.currentPath, super.key});
final Widget child;
final String currentPath;
@override
ConsumerState<ShellPage> createState() => _ShellPageState();
}
class _ShellPageState extends ConsumerState<ShellPage> {
static const double _compactHeaderThreshold = 34;
bool _showCompactHeader = false;
@override
void didUpdateWidget(covariant ShellPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentPath != widget.currentPath && _showCompactHeader) {
_showCompactHeader = false;
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(audioPlayerControllerProvider.notifier);
final canPop = GoRouter.of(context).canPop();
final selectedIndex = _tabs.indexWhere(
(tab) => tab.path == widget.currentPath,
);
final safeIndex = selectedIndex < 0 ? 0 : selectedIndex;
final header = _headerForPath(widget.currentPath);
return Scaffold(
backgroundColor: theme.colorScheme.background,
appBar: AppBar(
backgroundColor: theme.colorScheme.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
leading: canPop
? ShadIconButton.ghost(
onPressed: () => context.pop(),
icon: const Icon(LucideIcons.chevronLeft, size: 18),
)
: null,
title: AnimatedOpacity(
opacity: _showCompactHeader ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
header.title,
textAlign: TextAlign.center,
style: YantingText.listTitle.copyWith(
color: theme.colorScheme.foreground,
),
),
if (header.subtitle.isNotEmpty)
Text(
header.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: YantingText.meta.copyWith(
color: theme.colorScheme.mutedForeground,
fontSize: 12,
),
),
],
),
),
),
body: ColoredBox(
color: theme.colorScheme.background,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Stack(children: [Positioned.fill(child: widget.child)]),
),
),
bottomNavigationBar: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MiniPlayer(player: player, onToggle: controller.toggleAudio),
BottomTabBar(
items: yantingBottomTabItems,
selectedIndex: safeIndex,
onSelected: (index) => context.go(_tabs[index].path),
),
],
),
),
);
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) {
return false;
}
final next = notification.metrics.pixels > _compactHeaderThreshold;
if (next != _showCompactHeader && mounted) {
setState(() => _showCompactHeader = next);
}
return false;
}
}
_ShellHeader _headerForPath(String path) {
return switch (path) {
AppRoutes.reports => const _ShellHeader('研报', '全部已发布研报解读'),
AppRoutes.institutions => const _ShellHeader('机构', '可获取研报的机构'),
AppRoutes.listen => const _ShellHeader('听单', '已转音频的研报解读'),
AppRoutes.profile => const _ShellHeader('我的', ''),
_ => const _ShellHeader('研听', '全球机构研报中文解读'),
};
}
class _ShellHeader {
const _ShellHeader(this.title, this.subtitle);
final String title;
final String subtitle;
}
class _TabItem {
const _TabItem({required this.path});
final String path;
}
const List<_TabItem> _tabs = [
_TabItem(path: AppRoutes.home),
_TabItem(path: AppRoutes.reports),
_TabItem(path: AppRoutes.institutions),
_TabItem(path: AppRoutes.listen),
_TabItem(path: AppRoutes.profile),
];
-13
View File
@@ -1,13 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'app/bootstrap.dart';
export 'app.dart';
export 'data/api/report_data_source.dart';
export 'data/models/models.dart';
Future<void> main() async {
final app = await bootstrap();
runApp(ProviderScope(child: app));
}
-226
View File
@@ -1,226 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/providers.dart';
import '../features/detail/report_detail_page.dart';
import '../features/feed/feed_page.dart';
import '../features/home/home_page.dart';
import '../features/auth/login_page.dart';
import '../features/institutions/institution_detail_page.dart';
import '../features/institutions/institutions_page.dart';
import '../features/listen/listen_page.dart';
import '../features/profile/profile_page.dart';
import '../features/reports/reports_page.dart';
import '../features/settings/settings_page.dart';
import '../features/shell_page.dart';
import 'app_routes.dart';
final routerProvider = Provider<GoRouter>((ref) {
final dataSource = ref.read(reportDataSourceProvider);
return GoRouter(
initialLocation: AppRoutes.home,
routes: [
ShellRoute(
builder: (context, state, child) =>
ShellPage(currentPath: state.matchedLocation, child: child),
routes: [
GoRoute(
path: AppRoutes.home,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return FeedPage(
dataSource: dataSource,
onPlay: controller.startFromItem,
player: player,
onStartModuleAudio: controller.startModuleAudio,
onToggleAudio: controller.toggleAudio,
onSeekAudio: controller.seekAudio,
onSpeed: controller.cycleSpeed,
);
},
),
),
),
GoRoute(
path: AppRoutes.homeFeed,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return HomePage(
dataSource: dataSource,
onPlay: controller.startFromItem,
player: player,
onStartModuleAudio: controller.startModuleAudio,
onToggleAudio: controller.toggleAudio,
onSeekAudio: controller.seekAudio,
onSpeed: controller.cycleSpeed,
);
},
),
),
),
GoRoute(
path: AppRoutes.reports,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return ReportsPage(
dataSource: dataSource,
onPlay: controller.startFromItem,
player: player,
onStartModuleAudio: controller.startModuleAudio,
onToggleAudio: controller.toggleAudio,
onSeekAudio: controller.seekAudio,
onSpeed: controller.cycleSpeed,
);
},
),
),
),
GoRoute(
path: AppRoutes.institutions,
builder: (context, state) =>
_TabSurface(child: InstitutionsPage(dataSource: dataSource)),
),
GoRoute(
path: AppRoutes.listen,
builder: (context, state) => _TabSurface(
child: Consumer(
builder: (context, ref, _) {
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return ListenPage(
dataSource: dataSource,
onPlay: controller.startFromItem,
);
},
),
),
),
GoRoute(
path: AppRoutes.profile,
builder: (context, state) =>
_TabSurface(child: ProfilePage(dataSource: dataSource)),
),
],
),
GoRoute(
path: AppRoutes.reportDetail,
pageBuilder: (context, state) {
final id = state.pathParameters['id'] ?? '';
final args = state.extra as ReportDetailRouteArgs?;
return _slidePage(
state: state,
child: Consumer(
builder: (context, ref, _) {
final player = ref.watch(audioPlayerControllerProvider);
final controller = ref.read(
audioPlayerControllerProvider.notifier,
);
return ReportDetailPage(
reportId: id,
dataSource: args?.dataSource ?? dataSource,
player: player,
onStartAudio:
args?.onStartAudio ?? controller.startModuleAudio,
onToggleAudio: args?.onToggleAudio ?? controller.toggleAudio,
onSeekAudio: args?.onSeekAudio ?? controller.seekAudio,
onSpeed: args?.onSpeed ?? controller.cycleSpeed,
);
},
),
);
},
),
GoRoute(
path: AppRoutes.login,
pageBuilder: (context, state) {
final next = state.uri.queryParameters['next'];
return _slidePage(
state: state,
child: LoginPage(next: next),
);
},
),
GoRoute(
path: AppRoutes.institutionDetail,
pageBuilder: (context, state) {
final id = state.pathParameters['id'] ?? '';
final args = state.extra as InstitutionDetailRouteArgs?;
return _slidePage(
state: state,
child: InstitutionDetailPage(
institutionId: id,
dataSource: args?.dataSource ?? dataSource,
),
);
},
),
GoRoute(
path: AppRoutes.settings,
pageBuilder: (context, state) =>
_slidePage(state: state, child: const SettingsPage()),
),
],
);
});
CustomTransitionPage<void> _slidePage({
required GoRouterState state,
required Widget child,
}) {
return CustomTransitionPage<void>(
key: state.pageKey,
transitionDuration: const Duration(milliseconds: 260),
reverseTransitionDuration: const Duration(milliseconds: 220),
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.08, 0),
end: Offset.zero,
).animate(curved),
child: child,
),
);
},
);
}
class _TabSurface extends StatelessWidget {
const _TabSurface({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: ShadTheme.of(context).colorScheme.background,
child: child,
);
}
}
-87
View File
@@ -1,87 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../data/api/report_data_source.dart';
import '../data/models/models.dart';
import '../widgets/mini_player.dart';
abstract final class AppRoutes {
static const home = '/';
static const homeFeed = '/feed';
static const reports = '/reports';
static const institutions = '/institutions';
static const listen = '/listen';
static const profile = '/profile';
static const login = '/login';
static const settings = '/settings';
static const reportDetail = '/reports/:id';
static const institutionDetail = '/institutions/:id';
static String reportDetailPath(String id) => '/reports/$id';
static String institutionDetailPath(String id) => '/institutions/$id';
}
class ReportDetailRouteArgs {
const ReportDetailRouteArgs({
required this.dataSource,
required this.player,
required this.onStartAudio,
required this.onToggleAudio,
required this.onSeekAudio,
required this.onSpeed,
});
final ReportDataSource dataSource;
final PlayerStateModel player;
final void Function(
String audioId,
String reportId,
String title,
int durationSec,
)?
onStartAudio;
final VoidCallback? onToggleAudio;
final void Function(int delta)? onSeekAudio;
final VoidCallback? onSpeed;
}
class InstitutionDetailRouteArgs {
const InstitutionDetailRouteArgs({required this.dataSource});
final ReportDataSource dataSource;
}
void openReportDetail(
BuildContext context,
ReportDataSource dataSource,
ReportCardModel report, {
PlayerStateModel player = const PlayerStateModel(),
void Function(String audioId, String reportId, String title, int durationSec)?
onStartAudio,
VoidCallback? onToggleAudio,
void Function(int delta)? onSeekAudio,
VoidCallback? onSpeed,
}) {
context.push(
AppRoutes.reportDetailPath(report.id),
extra: ReportDetailRouteArgs(
dataSource: dataSource,
player: player,
onStartAudio: onStartAudio,
onToggleAudio: onToggleAudio,
onSeekAudio: onSeekAudio,
onSpeed: onSpeed,
),
);
}
void openInstitutionDetail(
BuildContext context,
ReportDataSource dataSource,
String institutionId,
) {
context.push(
AppRoutes.institutionDetailPath(institutionId),
extra: InstitutionDetailRouteArgs(dataSource: dataSource),
);
}
-45
View File
@@ -1,45 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:remixicon/remixicon.dart';
abstract final class AppIcons {
static const IconData sparkle = Remix.star_line;
static const IconData sparkleFill = Remix.star_fill;
static const IconData article = Remix.article_line;
static const IconData articleFill = Remix.article_fill;
static const IconData bank = Remix.bank_line;
static const IconData bankFill = Remix.bank_fill;
static const IconData headphones = Remix.headphone_line;
static const IconData headphonesFill = Remix.headphone_fill;
static const IconData user = Remix.user_3_line;
static const IconData userFill = Remix.user_3_fill;
static const IconData search = Remix.search_line;
static const IconData filter = Remix.equalizer_line;
static const IconData sort = Remix.arrow_down_line;
static const IconData arrowRight = Remix.arrow_right_s_line;
static const IconData arrowLeft = Remix.arrow_left_s_line;
static const IconData play = Remix.play_fill;
static const IconData pause = Remix.pause_fill;
static const IconData playCircle = Remix.play_circle_fill;
static const IconData heart = Remix.heart_3_line;
static const IconData heartFill = Remix.heart_3_fill;
static const IconData externalLink = Remix.external_link_line;
static const IconData warning = Remix.error_warning_line;
static const IconData music = Remix.music_2_line;
static const IconData disc = Remix.disc_line;
static const IconData history = Remix.history_line;
static const IconData settings = Remix.settings_3_line;
static const IconData fileList = Remix.file_list_3_line;
static const IconData shield = Remix.shield_check_line;
static IconData tabIcon(int index, {required bool selected}) {
return switch (index) {
0 => selected ? sparkleFill : sparkle,
1 => selected ? articleFill : article,
2 => selected ? bankFill : bank,
3 => selected ? headphonesFill : headphones,
4 => selected ? userFill : user,
_ => selected ? sparkleFill : sparkle,
};
}
}
-144
View File
@@ -1,144 +0,0 @@
import 'package:flutter/material.dart';
import 'yanting_text.dart';
import 'yanting_tokens.dart';
ThemeData buildAppTheme(Brightness brightness) {
final primary = brightness == Brightness.dark
? YantingDarkColors.primary
: YantingColors.primary;
final primaryForeground = brightness == Brightness.dark
? YantingDarkColors.primaryForeground
: YantingColors.primaryForeground;
final secondary = brightness == Brightness.dark
? YantingDarkColors.secondary
: YantingColors.secondary;
final secondaryForeground = brightness == Brightness.dark
? YantingDarkColors.secondaryForeground
: YantingColors.secondaryForeground;
final link = brightness == Brightness.dark
? YantingDarkColors.link
: YantingColors.link;
final card = brightness == Brightness.dark
? YantingDarkColors.card
: YantingColors.card;
final foreground = brightness == Brightness.dark
? YantingDarkColors.foreground
: YantingColors.foreground;
final destructive = brightness == Brightness.dark
? YantingDarkColors.destructive
: YantingColors.destructive;
final border = brightness == Brightness.dark
? YantingDarkColors.border
: YantingColors.border;
final background = brightness == Brightness.dark
? YantingDarkColors.background
: YantingColors.background;
final mutedForeground = brightness == Brightness.dark
? YantingDarkColors.mutedForeground
: YantingColors.mutedForeground;
final input = brightness == Brightness.dark
? YantingDarkColors.input
: YantingColors.input;
final scheme = ColorScheme.fromSeed(
seedColor: primary,
brightness: brightness,
primary: primary,
onPrimary: primaryForeground,
secondary: secondary,
onSecondary: secondaryForeground,
tertiary: link,
surface: card,
onSurface: foreground,
error: destructive,
outline: border,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
fontFamily: YantingText.fontFamily,
fontFamilyFallback: YantingText.fontFallback,
brightness: brightness,
scaffoldBackgroundColor: background,
appBarTheme: AppBarTheme(
backgroundColor: background,
foregroundColor: foreground,
elevation: 0,
centerTitle: false,
titleTextStyle: YantingText.sectionTitle,
surfaceTintColor: Colors.transparent,
),
cardTheme: CardThemeData(
color: card,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(YantingRadius.xl)),
side: BorderSide(color: border),
),
),
dividerTheme: DividerThemeData(
color: border,
thickness: 1,
space: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: background,
hintStyle: YantingText.body.copyWith(color: mutedForeground),
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: BorderSide(color: input),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: BorderSide(color: input),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
borderSide: BorderSide(color: foreground),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: foreground,
contentTextStyle: YantingText.body.copyWith(color: background),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(YantingRadius.md),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: background,
indicatorColor: Colors.transparent,
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => YantingText.meta.copyWith(
color: states.contains(WidgetState.selected)
? foreground
: mutedForeground,
fontSize: 11,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w600
: FontWeight.w400,
),
),
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
color: states.contains(WidgetState.selected)
? foreground
: mutedForeground,
),
),
),
textTheme: const TextTheme(
headlineSmall: YantingText.appTitle,
titleLarge: YantingText.sectionTitle,
titleMedium: YantingText.cardTitle,
bodyLarge: YantingText.body,
bodyMedium: YantingText.sub,
bodySmall: YantingText.meta,
labelLarge: YantingText.chip,
labelSmall: YantingText.badge,
),
);
}
-6
View File
@@ -1,6 +0,0 @@
export 'app_theme.dart';
export 'yanting_shad_theme.dart';
export 'theme_controller.dart';
export 'yanting_text.dart';
export 'yanting_tokens.dart';
export 'wise_tokens.dart';
-42
View File
@@ -1,42 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _themeModeKey = 'theme_mode';
final themeModeProvider = StateNotifierProvider<ThemeModeController, ThemeMode>(
(ref) => ThemeModeController(),
);
class ThemeModeController extends StateNotifier<ThemeMode> {
ThemeModeController() : super(ThemeMode.system) {
unawaited(_loadSavedMode());
}
Future<void> _loadSavedMode() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_themeModeKey);
if (raw == null) return;
state = _decode(raw);
}
Future<void> setMode(ThemeMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeModeKey, _encode(mode));
}
ThemeMode _decode(String raw) => switch (raw) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
String _encode(ThemeMode mode) => switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
}
-59
View File
@@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import 'yanting_tokens.dart';
final class WiseColors {
static const primary = YantingColors.foreground;
static const primarySoft = YantingColors.primaryForeground;
static const secondary = YantingColors.primary;
static const secondary200 = YantingColors.brandSoft;
static const accent = YantingColors.link;
static const canvas = YantingColors.background;
static const ink = YantingColors.foreground;
static const ink700 = YantingColors.secondaryForeground;
static const textSecondary = YantingColors.mutedForeground;
static const textTertiary = YantingColors.mutedForeground;
static const surface = YantingColors.card;
static const border = YantingColors.border;
static const positive = YantingColors.chart2;
static const warning = Color(0xFF9A6A00);
static const negative = YantingColors.destructive;
}
final class WiseSpacing {
static const x1 = YantingSpacing.x1;
static const x2 = YantingSpacing.x2;
static const x3 = YantingSpacing.cardGap;
static const x4 = YantingSpacing.screenX;
static const x5 = YantingSpacing.screenX;
static const x6 = YantingSpacing.x6;
static const x8 = YantingSpacing.x8;
static const x10 = YantingSpacing.x10;
}
final class WiseRadius {
static const sm = YantingRadius.sm;
static const md = YantingRadius.xl;
static const lg = 24.0;
static const pill = YantingRadius.pill;
}
final class WiseMotion {
static const short = Duration(milliseconds: 200);
static const base = Duration(milliseconds: 350);
static const curve = Cubic(0.8, 0.05, 0.2, 0.95);
}
final class WiseShadows {
static const card = <BoxShadow>[];
static const elevated = <BoxShadow>[];
}
const wiseFontStack = [
'DM Sans',
'PingFang SC',
'Microsoft YaHei',
'Helvetica Neue',
'Arial',
'sans-serif',
];
-111
View File
@@ -1,111 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'yanting_text.dart';
import 'yanting_tokens.dart';
ShadThemeData buildYantingShadTheme() =>
_buildShadTheme(brightness: Brightness.light);
ShadThemeData buildYantingDarkShadTheme() =>
_buildShadTheme(brightness: Brightness.dark);
ShadThemeData _buildShadTheme({required Brightness brightness}) {
final colors = brightness == Brightness.dark
? ShadColorScheme(
background: YantingDarkColors.background,
foreground: YantingDarkColors.foreground,
card: YantingDarkColors.card,
cardForeground: YantingDarkColors.foreground,
popover: YantingDarkColors.card,
popoverForeground: YantingDarkColors.foreground,
primary: YantingDarkColors.primary,
primaryForeground: YantingDarkColors.primaryForeground,
secondary: YantingDarkColors.secondary,
secondaryForeground: YantingDarkColors.secondaryForeground,
muted: YantingDarkColors.muted,
mutedForeground: YantingDarkColors.mutedForeground,
accent: YantingDarkColors.brandSoft,
accentForeground: YantingDarkColors.primaryForeground,
destructive: YantingDarkColors.destructive,
destructiveForeground: YantingDarkColors.background,
border: YantingDarkColors.border,
input: YantingDarkColors.input,
ring: YantingDarkColors.primary,
selection: YantingDarkColors.foreground,
custom: {
'brandSoft': YantingDarkColors.brandSoft,
'brandSoftBorder': YantingDarkColors.brandSoftBorder,
'link': YantingDarkColors.link,
'chart2': YantingDarkColors.chart2,
'warning': YantingDarkColors.warning,
'warningSoft': YantingDarkColors.warningSoft,
'warningSoftBorder': YantingDarkColors.warningSoftBorder,
'warningSoftForeground': YantingDarkColors.warningSoftForeground,
},
)
: ShadColorScheme(
background: YantingColors.background,
foreground: YantingColors.foreground,
card: YantingColors.card,
cardForeground: YantingColors.foreground,
popover: YantingColors.card,
popoverForeground: YantingColors.foreground,
primary: YantingColors.primary,
primaryForeground: YantingColors.primaryForeground,
secondary: YantingColors.secondary,
secondaryForeground: YantingColors.secondaryForeground,
muted: YantingColors.muted,
mutedForeground: YantingColors.mutedForeground,
accent: YantingColors.brandSoft,
accentForeground: YantingColors.primaryForeground,
destructive: YantingColors.destructive,
destructiveForeground: YantingColors.background,
border: YantingColors.border,
input: YantingColors.input,
ring: YantingColors.primary,
selection: YantingColors.foreground,
custom: {
'brandSoft': YantingColors.brandSoft,
'brandSoftBorder': YantingColors.brandSoftBorder,
'link': YantingColors.link,
'chart2': YantingColors.chart2,
'warning': YantingColors.warning,
'warningSoft': YantingColors.warningSoft,
'warningSoftBorder': YantingColors.warningSoftBorder,
'warningSoftForeground': YantingColors.warningSoftForeground,
},
);
final textTheme = ShadTextTheme(
family: YantingText.fontFamily,
h1Large: YantingText.appTitle.copyWith(color: colors.foreground),
h1: YantingText.appTitle.copyWith(color: colors.foreground),
h2: YantingText.sectionTitle.copyWith(color: colors.foreground),
h3: YantingText.cardTitle.copyWith(color: colors.foreground),
h4: YantingText.listTitle.copyWith(color: colors.foreground),
p: YantingText.body.copyWith(color: colors.foreground),
blockquote: YantingText.body.copyWith(color: colors.mutedForeground),
table: YantingText.meta.copyWith(color: colors.mutedForeground),
list: YantingText.body.copyWith(color: colors.foreground),
lead: YantingText.sub.copyWith(color: colors.mutedForeground),
large: YantingText.cardTitle.copyWith(color: colors.foreground),
small: YantingText.badge.copyWith(color: colors.mutedForeground),
muted: YantingText.meta.copyWith(color: colors.mutedForeground),
googleFontBuilder: GoogleFonts.dmSans,
);
return ShadThemeData(
brightness: brightness,
colorScheme: colors,
radius: BorderRadius.circular(YantingRadius.base),
cardTheme: ShadCardTheme(
padding: const EdgeInsets.all(YantingSpacing.cardPadding),
radius: BorderRadius.circular(YantingRadius.xl),
border: ShadBorder.all(color: colors.border),
shadows: const [],
),
textTheme: textTheme,
);
}
-97
View File
@@ -1,97 +0,0 @@
import 'package:flutter/material.dart';
import 'yanting_tokens.dart';
abstract final class YantingText {
static const fontFamily = 'DM Sans';
static const fontFallback = [
'PingFang SC',
'Microsoft YaHei',
'Helvetica Neue',
'Arial',
'sans-serif',
];
static const appTitle = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 34,
height: 1.15,
letterSpacing: 0,
fontWeight: FontWeight.w800,
);
static const sectionTitle = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 22,
height: 1.2,
letterSpacing: 0,
fontWeight: FontWeight.w700,
);
static const cardTitle = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 19,
height: 1.4,
letterSpacing: 0,
fontWeight: FontWeight.w600,
);
static const listTitle = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 16.5,
height: 1.45,
letterSpacing: 0,
fontWeight: FontWeight.w600,
);
static const body = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.6,
letterSpacing: 0,
fontWeight: FontWeight.w400,
);
static const sub = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.45,
letterSpacing: 0,
fontWeight: FontWeight.w400,
);
static const meta = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 13,
height: 1.5,
letterSpacing: 0,
fontWeight: FontWeight.w400,
fontFeatures: YantingTypographyFeatures.tabularNums,
);
static const chip = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 15,
height: 1.2,
letterSpacing: 0,
fontWeight: FontWeight.w500,
);
static const badge = TextStyle(
fontFamily: fontFamily,
fontFamilyFallback: fontFallback,
fontSize: 12,
height: 1.5,
letterSpacing: 0,
fontWeight: FontWeight.w500,
fontFeatures: YantingTypographyFeatures.tabularNums,
);
}
-94
View File
@@ -1,94 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
abstract final class YantingColors {
static const background = Color(0xFFFFFFFF);
static const foreground = Color(0xFF1A1A1A);
static const card = Color(0xFFFFFFFF);
static const primary = Color(0xFF95E300);
static const primaryForeground = Color(0xFF365314);
static const secondary = Color(0xFFF4F4F5);
static const secondaryForeground = Color(0xFF27272A);
static const muted = Color(0xFFF7F7F7);
static const mutedForeground = Color(0xFF71717A);
static const border = Color(0xFFE5E5E5);
static const input = Color(0xFFE5E5E5);
static const destructive = Color(0xFFEF4444);
static const warning = Color(0xFF9A6500);
static const warningSoft = Color(0xFFFDE68A);
static const warningSoftBorder = Color(0xFFF5D26A);
static const warningSoftForeground = Color(0xFF7C4A00);
static const chart2 = Color(0xFF84CC16);
static const brandSoft = Color(0xFFECFCCB);
static const brandSoftBorder = Color(0xFFD6F5A8);
static const link = Color(0xFF2563EB);
static const canvas = background;
}
abstract final class YantingDarkColors {
static const background = Color(0xFF09090B);
static const foreground = Color(0xFFF4F4F5);
static const card = Color(0xFF111113);
static const primary = Color(0xFF95E300);
static const primaryForeground = Color(0xFF0F1A00);
static const secondary = Color(0xFF1F1F23);
static const secondaryForeground = Color(0xFFE4E4E7);
static const muted = Color(0xFF18181B);
static const mutedForeground = Color(0xFFA1A1AA);
static const border = Color(0xFF27272A);
static const input = Color(0xFF27272A);
static const destructive = Color(0xFFF87171);
static const warning = Color(0xFFF59E0B);
static const warningSoft = Color(0xFF2A2412);
static const warningSoftBorder = Color(0xFF665113);
static const warningSoftForeground = Color(0xFFFBBF24);
static const chart2 = Color(0xFF84CC16);
static const brandSoft = Color(0xFF1C2B00);
static const brandSoftBorder = Color(0xFF304800);
static const link = Color(0xFF8AB4FF);
static const canvas = background;
}
abstract final class YantingSpacing {
static const x1 = 4.0;
static const x2 = 8.0;
static const x3 = 12.0;
static const cardGap = 14.0;
static const x4 = 16.0;
static const cardPadding = 18.0;
static const screenX = 20.0;
static const x6 = 24.0;
static const sectionGap = 30.0;
static const x8 = 32.0;
static const x10 = 40.0;
static const tabBarHeight = 56.0;
}
abstract final class YantingRadius {
static const base = 7.2;
static const sm = 3.2;
static const md = 5.2;
static const xl = 11.2;
static const pill = 9999.0;
}
abstract final class YantingBorders {
static const card = BorderSide(color: YantingColors.border);
static const soft = BorderSide(color: YantingColors.brandSoftBorder);
}
abstract final class YantingTypographyFeatures {
static const tabularNums = [FontFeature.tabularFigures()];
}
extension YantingShadColorSchemeX on ShadColorScheme {
Color get brandSoft => custom['brandSoft'] ?? accent;
Color get brandSoftBorder => custom['brandSoftBorder'] ?? border;
Color get link => custom['link'] ?? primary;
Color get warning => custom['warning'] ?? destructive;
Color get warningSoft => custom['warningSoft'] ?? muted;
Color get warningSoftBorder => custom['warningSoftBorder'] ?? border;
Color get warningSoftForeground =>
custom['warningSoftForeground'] ?? foreground;
Color get chart2 => custom['chart2'] ?? primary;
}
-113
View File
@@ -1,113 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
class AppButton extends StatelessWidget {
const AppButton({
required this.label,
required this.onPressed,
this.icon,
this.kind = AppButtonKind.primary,
this.expand = false,
this.compact = false,
super.key,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final AppButtonKind kind;
final bool expand;
final bool compact;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final variant = switch (kind) {
AppButtonKind.primary => ShadButtonVariant.primary,
AppButtonKind.dark => ShadButtonVariant.primary,
AppButtonKind.accent => ShadButtonVariant.secondary,
AppButtonKind.ghost => ShadButtonVariant.outline,
};
final palette = switch (kind) {
AppButtonKind.primary => (null, null, null),
AppButtonKind.dark => (
colors.foreground,
colors.background,
colors.foreground.withValues(alpha: 0.9),
),
AppButtonKind.accent => (
colors.brandSoft,
colors.primaryForeground,
colors.brandSoftBorder,
),
AppButtonKind.ghost => (null, null, null),
};
final button = ShadButton.raw(
variant: variant,
enabled: onPressed != null,
onPressed: onPressed,
width: expand ? double.infinity : null,
height: compact ? 36 : 44,
padding: EdgeInsets.symmetric(horizontal: compact ? 16 : 20),
backgroundColor: palette.$1,
foregroundColor: palette.$2,
hoverBackgroundColor: palette.$3,
leading: icon == null ? null : Icon(icon, size: compact ? 15 : 16),
gap: compact ? 5 : 7,
textStyle: (compact ? YantingText.badge : YantingText.body).copyWith(
color: palette.$2,
fontWeight: FontWeight.w600,
),
child: Text(label),
);
return expand ? SizedBox(width: double.infinity, child: button) : button;
}
}
class AppIconButton extends StatelessWidget {
const AppIconButton({
required this.icon,
required this.onPressed,
this.kind = AppButtonKind.ghost,
super.key,
});
final IconData icon;
final VoidCallback? onPressed;
final AppButtonKind kind;
@override
Widget build(BuildContext context) {
final iconWidget = Icon(icon, size: 16);
final colors = ShadTheme.of(context).colorScheme;
return switch (kind) {
AppButtonKind.primary => ShadIconButton(
onPressed: onPressed,
icon: iconWidget,
),
AppButtonKind.dark => ShadIconButton(
onPressed: onPressed,
backgroundColor: colors.foreground,
foregroundColor: colors.background,
hoverBackgroundColor: colors.foreground.withValues(alpha: 0.9),
icon: iconWidget,
),
AppButtonKind.accent => ShadIconButton.secondary(
onPressed: onPressed,
backgroundColor: colors.brandSoft,
foregroundColor: colors.primaryForeground,
hoverBackgroundColor: colors.brandSoftBorder,
icon: iconWidget,
),
AppButtonKind.ghost => ShadIconButton.outline(
onPressed: onPressed,
icon: iconWidget,
),
};
}
}
enum AppButtonKind { primary, dark, accent, ghost }
-77
View File
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_tokens.dart';
class AppCard extends StatelessWidget {
const AppCard({
required this.child,
this.onTap,
this.padding = const EdgeInsets.all(YantingSpacing.cardPadding),
this.color = YantingColors.card,
this.borderColor = YantingColors.border,
super.key,
});
final Widget child;
final VoidCallback? onTap;
final EdgeInsetsGeometry padding;
final Color color;
final Color borderColor;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final colors = theme.colorScheme;
final radius = theme.radius.resolve(TextDirection.ltr);
final decoration = BoxDecoration(
color: color == YantingColors.card ? colors.card : color,
borderRadius: radius,
border: Border.all(
color: borderColor == YantingColors.border
? colors.border
: borderColor,
),
);
if (onTap == null) {
return DecoratedBox(
decoration: decoration,
child: Padding(padding: padding, child: child),
);
}
return Material(
color: Colors.transparent,
borderRadius: radius,
child: Ink(
decoration: decoration,
child: InkWell(
borderRadius: radius,
splashColor: colors.mutedForeground.withValues(alpha: 0.08),
highlightColor: colors.mutedForeground.withValues(alpha: 0.04),
onTap: onTap,
child: Padding(padding: padding, child: child),
),
),
);
}
}
class HeroReportCard extends StatelessWidget {
const HeroReportCard({required this.child, this.onTap, super.key});
final Widget child;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
onTap: onTap,
color: colors.brandSoft,
borderColor: colors.brandSoftBorder,
padding: const EdgeInsets.all(YantingSpacing.cardPadding),
child: child,
);
}
}
-95
View File
@@ -1,95 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_tokens.dart';
class AppBadge extends StatelessWidget {
const AppBadge({
required this.text,
this.icon,
this.kind = BadgeKind.neutral,
super.key,
});
final String text;
final IconData? icon;
final BadgeKind kind;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final child = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[Icon(icon, size: 12), const SizedBox(width: 4)],
Text(text),
],
);
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(YantingRadius.sm),
side: kind == BadgeKind.tier || kind == BadgeKind.warning
? BorderSide(color: colors.border)
: BorderSide.none,
);
return switch (kind) {
BadgeKind.brand => ShadBadge(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
child: child,
),
BadgeKind.audio || BadgeKind.neutral => ShadBadge.secondary(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
child: child,
),
BadgeKind.tier => ShadBadge.outline(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
foregroundColor: colors.mutedForeground,
child: child,
),
BadgeKind.warning => ShadBadge.destructive(
shape: shape,
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
backgroundColor: colors.warningSoft,
foregroundColor: colors.warningSoftForeground,
hoverBackgroundColor: colors.warningSoftBorder,
child: child,
),
};
}
}
enum BadgeKind { brand, audio, tier, warning, neutral }
class AppChip extends StatelessWidget {
const AppChip({
required this.label,
this.selected = false,
this.onTap,
super.key,
});
final String label;
final bool selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return ShadBadge.secondary(
onPressed: onTap,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 9),
backgroundColor: selected ? colors.foreground : colors.secondary,
hoverBackgroundColor: selected
? colors.foreground.withValues(alpha: 0.9)
: colors.border,
foregroundColor: selected
? colors.background
: colors.secondaryForeground,
child: Text(label),
);
}
}
-129
View File
@@ -1,129 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_icons.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
class BottomTabBarItem {
const BottomTabBarItem({
required this.label,
required this.icon,
required this.selectedIcon,
});
final String label;
final IconData icon;
final IconData selectedIcon;
}
class BottomTabBar extends StatelessWidget {
const BottomTabBar({
required this.items,
required this.selectedIndex,
required this.onSelected,
super.key,
});
final List<BottomTabBarItem> items;
final int selectedIndex;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return DecoratedBox(
decoration: const BoxDecoration(color: Colors.transparent),
child: SizedBox(
height: YantingSpacing.tabBarHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.background,
border: Border(top: BorderSide(color: colors.border)),
),
child: Row(
children: [
for (var index = 0; index < items.length; index++)
Expanded(
child: _BottomTabButton(
item: items[index],
selected: index == selectedIndex,
onTap: () => onSelected(index),
),
),
],
),
),
),
);
}
}
class _BottomTabButton extends StatelessWidget {
const _BottomTabButton({
required this.item,
required this.selected,
required this.onTap,
});
final BottomTabBarItem item;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final color = selected ? colors.foreground : colors.mutedForeground;
return InkWell(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
selected ? item.selectedIcon : item.icon,
size: 22,
color: color,
),
const SizedBox(height: 4),
Text(
item.label,
style: YantingText.meta.copyWith(
color: color,
fontSize: 11,
letterSpacing: 0,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
);
}
}
const yantingBottomTabItems = [
BottomTabBarItem(
label: '推荐',
icon: AppIcons.sparkle,
selectedIcon: AppIcons.sparkleFill,
),
BottomTabBarItem(
label: '研报',
icon: AppIcons.article,
selectedIcon: AppIcons.articleFill,
),
BottomTabBarItem(
label: '机构',
icon: AppIcons.bank,
selectedIcon: AppIcons.bankFill,
),
BottomTabBarItem(
label: '听单',
icon: AppIcons.headphones,
selectedIcon: AppIcons.headphonesFill,
),
BottomTabBarItem(
label: '我的',
icon: AppIcons.user,
selectedIcon: AppIcons.userFill,
),
];
-145
View File
@@ -1,145 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/models/models.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
import 'app_card.dart';
import 'badges.dart';
class InstitutionCard extends StatelessWidget {
const InstitutionCard({
required this.institution,
required this.onTap,
super.key,
});
final Institution institution;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final initials = institution.nameCn.isEmpty
? ''
: institution.nameCn.characters.take(2).toString();
return AppCard(
onTap: onTap,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
InstitutionLogo(
logoUrl: institution.logoUrl,
initials: initials,
size: 48,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
institution.nameCn,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.listTitle.copyWith(
fontWeight: FontWeight.w700,
color: colors.foreground,
),
),
if (institution.nameEn.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
institution.nameEn,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.meta,
),
],
const SizedBox(height: 8),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
if (institution.institutionType.isNotEmpty)
AppBadge(
text: institution.institutionType,
kind: BadgeKind.tier,
),
for (final topic in institution.coveredTopics.take(3))
AppBadge(text: topic),
],
),
],
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${institution.reportCount}',
style: YantingText.sectionTitle.copyWith(
fontSize: 20,
fontFeatures: YantingTypographyFeatures.tabularNums,
color: colors.foreground,
),
),
Text('份研报', style: YantingText.meta.copyWith(fontSize: 11)),
],
),
],
),
);
}
}
class InstitutionLogo extends StatelessWidget {
const InstitutionLogo({
required this.logoUrl,
required this.initials,
required this.size,
super.key,
});
final String logoUrl;
final String initials;
final double size;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final fallback = DecoratedBox(
decoration: BoxDecoration(
color: colors.secondary,
border: Border.all(color: colors.border),
borderRadius: BorderRadius.circular(size * 0.25),
),
child: Center(
child: Text(
initials,
style: YantingText.meta.copyWith(
color: colors.secondaryForeground,
fontSize: 14,
fontWeight: FontWeight.w700,
fontFeatures: null,
),
),
),
);
if (logoUrl.isEmpty) {
return SizedBox(width: size, height: size, child: fallback);
}
return ClipRRect(
borderRadius: BorderRadius.circular(size * 0.25),
child: Image.network(
logoUrl,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => fallback,
),
);
}
}
-276
View File
@@ -1,276 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../data/models/models.dart';
import '../theme/app_icons.dart';
import '../theme/yanting_text.dart';
import '../theme/wise_tokens.dart';
import 'app_buttons.dart';
import 'app_card.dart';
class PlayerStateModel {
const PlayerStateModel({
this.audioId = '',
this.reportId = '',
this.title = '',
this.durationSec = 0,
this.positionSec = 0,
this.playing = false,
this.speed = 1.0,
});
final String audioId;
final String reportId;
final String title;
final int durationSec;
final int positionSec;
final bool playing;
final double speed;
bool get hasAudio => audioId.isNotEmpty;
PlayerStateModel copyWith({
String? audioId,
String? reportId,
String? title,
int? durationSec,
int? positionSec,
bool? playing,
double? speed,
}) {
return PlayerStateModel(
audioId: audioId ?? this.audioId,
reportId: reportId ?? this.reportId,
title: title ?? this.title,
durationSec: durationSec ?? this.durationSec,
positionSec: positionSec ?? this.positionSec,
playing: playing ?? this.playing,
speed: speed ?? this.speed,
);
}
}
class MiniPlayer extends StatelessWidget {
const MiniPlayer({required this.player, required this.onToggle, super.key});
final PlayerStateModel player;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
if (!player.hasAudio) return const SizedBox.shrink();
final colors = ShadTheme.of(context).colorScheme;
final ratio = player.durationSec == 0
? 0.0
: player.positionSec / player.durationSec;
return DecoratedBox(
decoration: BoxDecoration(
color: colors.secondary,
border: Border(top: BorderSide(color: colors.border)),
),
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: ratio.clamp(0, 1),
child: SizedBox(
height: 2,
child: ColoredBox(color: colors.primary),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
AppIcons.disc,
color: colors.primaryForeground,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
player.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w600,
fontFeatures: null,
),
),
const SizedBox(height: 2),
Text(
'${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: YantingText.meta.copyWith(fontSize: 11),
),
],
),
),
ShadIconButton.ghost(
onPressed: onToggle,
icon: Icon(
player.playing ? AppIcons.pause : AppIcons.playCircle,
size: player.playing ? 24 : 28,
),
),
],
),
),
],
),
);
}
}
class PlayerCard extends StatelessWidget {
const PlayerCard({
required this.title,
required this.durationSec,
required this.player,
required this.onStart,
required this.onToggle,
required this.onSeek,
required this.onSpeed,
super.key,
});
final String title;
final int durationSec;
final PlayerStateModel player;
final VoidCallback onStart;
final VoidCallback onToggle;
final void Function(int delta) onSeek;
final VoidCallback onSpeed;
@override
Widget build(BuildContext context) {
final active = player.hasAudio && player.title == title;
final position = active ? player.positionSec : 0;
final ratio = durationSec == 0 ? 0.0 : position / durationSec;
final colors = ShadTheme.of(context).colorScheme;
return AppCard(
color: colors.secondary,
borderColor: colors.border,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('音频解读', style: YantingText.listTitle),
const SizedBox(height: 6),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: YantingText.meta.copyWith(fontSize: 12.5),
),
const SizedBox(height: 16),
ShadProgress(value: ratio.clamp(0, 1)),
const SizedBox(height: WiseSpacing.x2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
formatDuration(position),
style: YantingText.meta.copyWith(fontSize: 11),
),
Text(
formatDuration(durationSec),
style: YantingText.meta.copyWith(fontSize: 11),
),
],
),
const SizedBox(height: 18),
SizedBox(
height: 56,
child: Stack(
alignment: Alignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_SkipButton(label: '-15', onPressed: () => onSeek(-15)),
const SizedBox(width: 26),
SizedBox(
width: 56,
height: 56,
child: AppIconButton(
kind: AppButtonKind.primary,
onPressed: active ? onToggle : onStart,
icon: active && player.playing
? AppIcons.pause
: AppIcons.play,
),
),
const SizedBox(width: 26),
_SkipButton(label: '+15', onPressed: () => onSeek(15)),
],
),
Align(
alignment: Alignment.centerRight,
child: ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: onSpeed,
child: Text('${player.speed.toStringAsFixed(1)}x'),
),
),
],
),
),
const SizedBox(height: 16),
Text(
'真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。',
style: YantingText.meta.copyWith(fontSize: 11.5, height: 1.6),
),
],
),
);
}
}
class _SkipButton extends StatelessWidget {
const _SkipButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: colors.foreground,
minimumSize: const Size(40, 40),
padding: EdgeInsets.zero,
),
child: Text(
label,
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w600,
),
),
);
}
}
-60
View File
@@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_text.dart';
import '../theme/yanting_tokens.dart';
class PageHeader extends StatelessWidget {
const PageHeader({required this.title, this.subtitle, super.key});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: YantingText.appTitle),
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: YantingText.sub.copyWith(color: colors.mutedForeground),
),
],
],
),
);
}
}
class SectionTitle extends StatelessWidget {
const SectionTitle({required this.title, this.icon, super.key});
final String title;
final IconData? icon;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(
top: YantingSpacing.sectionGap,
bottom: 16,
),
child: Row(
children: [
Text(title, style: YantingText.sectionTitle),
if (icon != null) ...[
const SizedBox(width: 6),
Icon(icon, size: 18, color: colors.mutedForeground),
],
],
),
);
}
}
-74
View File
@@ -1,74 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'app_buttons.dart';
import 'states.dart';
Future<void> showLoginSheet(
BuildContext context, {
String reason = '登录后保存当前动作',
VoidCallback? onPhoneLogin,
VoidCallback? onSecondaryLogin,
}) {
return showShadSheet<void>(
context: context,
side: ShadSheetSide.bottom,
builder: (context) => ShadSheet(
title: const Text('登录研听'),
description: Text(reason),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppButton(
label: '使用手机号继续',
icon: Icons.phone_iphone,
expand: true,
onPressed: () {
Navigator.pop(context);
onPhoneLogin?.call();
showAppToast(context, '已使用本地登录态继续');
},
),
const SizedBox(height: 8),
AppButton(
label: '微信 / Apple 登录占位',
icon: Icons.account_circle_outlined,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () {
Navigator.pop(context);
onSecondaryLogin?.call();
showAppToast(context, '已使用本地登录态继续');
},
),
],
),
),
);
}
Future<void> showOutboundSheet(
BuildContext context, {
required String title,
VoidCallback? onConfirm,
}) {
return showShadSheet<void>(
context: context,
side: ShadSheetSide.bottom,
builder: (context) => ShadSheet(
title: const Text('即将打开外部服务'),
description: Text('$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。'),
child: AppButton(
label: '确认并记录占位事件',
icon: Icons.open_in_new,
kind: AppButtonKind.accent,
expand: true,
onPressed: () {
Navigator.pop(context);
onConfirm?.call();
showAppToast(context, '外跳事件接口待接入');
},
),
),
);
}
-192
View File
@@ -1,192 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/yanting_tokens.dart';
import 'app_buttons.dart';
import 'app_card.dart';
class LoadingState extends StatelessWidget {
const LoadingState({this.label = '正在加载研报解读', super.key});
final String label;
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(YantingSpacing.screenX),
itemCount: 4,
separatorBuilder: (_, _) =>
const SizedBox(height: YantingSpacing.cardGap),
itemBuilder: (context, index) => const SkeletonCard(),
);
}
}
class SkeletonCard extends StatelessWidget {
const SkeletonCard({super.key});
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SkeletonLine(width: 96),
SizedBox(height: YantingSpacing.cardGap),
SkeletonLine(width: double.infinity, height: 18),
SizedBox(height: YantingSpacing.x2),
SkeletonLine(width: 240),
SizedBox(height: YantingSpacing.cardGap),
SkeletonLine(width: 160),
],
),
);
}
}
class SkeletonLine extends StatelessWidget {
const SkeletonLine({required this.width, this.height = 12, super.key});
final double width;
final double height;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return _PulsingSkeleton(
width: width,
height: height,
color: theme.colorScheme.muted,
);
}
}
class _PulsingSkeleton extends StatefulWidget {
const _PulsingSkeleton({
required this.color,
required this.width,
required this.height,
});
final Color color;
final double width;
final double height;
@override
State<_PulsingSkeleton> createState() => _PulsingSkeletonState();
}
class _PulsingSkeletonState extends State<_PulsingSkeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return FadeTransition(
opacity: Tween<double>(
begin: 0.4,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)),
child: Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: widget.color,
borderRadius: theme.radius,
),
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState({
required this.title,
required this.message,
this.icon = Icons.search_off,
this.actionLabel,
this.onAction,
super.key,
});
final String title;
final String message;
final IconData icon;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(YantingSpacing.x6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 42, color: theme.colorScheme.foreground),
const SizedBox(height: YantingSpacing.cardGap),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: YantingSpacing.x2),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
if (actionLabel != null) ...[
const SizedBox(height: YantingSpacing.x4),
AppButton(
label: actionLabel!,
onPressed: onAction,
kind: AppButtonKind.ghost,
),
],
],
),
),
);
}
}
class ErrorState extends StatelessWidget {
const ErrorState({required this.message, this.onRetry, super.key});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return EmptyState(
icon: Icons.cloud_off_outlined,
title: '内容暂时加载失败',
message: message,
actionLabel: onRetry == null ? null : '重试',
onAction: onRetry,
);
}
}
Future<bool?> showAppToast(BuildContext context, String message) {
// ShadToaster.of(context).show(ShadToast(title: Text(message)));
return Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1,
backgroundColor: const Color(0xCC111111),
textColor: Colors.white,
fontSize: 16,
);
}
-9
View File
@@ -1,9 +0,0 @@
export 'app_buttons.dart';
export 'app_card.dart';
export 'badges.dart';
export 'bottom_tab_bar.dart';
export 'institution_card.dart';
export 'mini_player.dart';
export 'page_header.dart';
export 'sheets.dart';
export 'states.dart';
-727
View File
@@ -1,727 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
boxy:
dependency: transitive
description:
name: boxy
sha256: "42ccafe13b2893878042acc5b7e2446025328e11a3197b0bb78db42ff76aa3f0"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
extended_image:
dependency: transitive
description:
name: extended_image
sha256: f6cbb1d798f51262ed1a3d93b4f1f2aa0d76128df39af18ecb77fa740f88b2e0
url: "https://pub.dev"
source: hosted
version: "10.0.1"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_animate:
dependency: transitive
description:
name: flutter_animate
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_hooks:
dependency: "direct main"
description:
name: flutter_hooks
sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42"
url: "https://pub.dev"
source: hosted
version: "0.21.3+1"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_riverpod:
dependency: transitive
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_shaders:
dependency: transitive
description:
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
fluttertoast:
dependency: "direct main"
description:
name: fluttertoast
sha256: "144ddd74d49c865eba47abe31cbc746c7b311c82d6c32e571fd73c4264b740e2"
url: "https://pub.dev"
source: hosted
version: "9.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: d8f590a69729f719177ea68eb1e598295e8dbc41bbc247fed78b2c8a25660d7c
url: "https://pub.dev"
source: hosted
version: "16.3.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hooks_riverpod:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
lucide_icons_flutter:
dependency: transitive
description:
name: lucide_icons_flutter
sha256: "7c5dc01a32a9905ae34e2d84224e92d6d0c42acf8926df9e01c35a1446bf1b69"
url: "https://pub.dev"
source: hosted
version: "3.1.14+2"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
phosphor_flutter:
dependency: "direct main"
description:
name: phosphor_flutter
sha256: "8a14f238f28a0b54842c5a4dc20676598dd4811fcba284ed828bd5a262c11fde"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
remixicon:
dependency: "direct main"
description:
name: remixicon
sha256: "4b8e334b78b0fbf05fb7abe1b48f3c3df9e4a11ab767e3f3e7f1cc36dc1e046e"
url: "https://pub.dev"
source: hosted
version: "4.9.3"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
serial_csv:
dependency: transitive
description:
name: serial_csv
sha256: "2d62bb70cb3ce7251383fc86ea9aae1298ab1e57af6ef4e93b6a9751c5c268dd"
url: "https://pub.dev"
source: hosted
version: "0.5.2"
shadcn_ui:
dependency: "direct main"
description:
name: shadcn_ui
sha256: "6c06f2bcebd8734b9ed0bf3f63ef5c71981573d5664923589b0302f8280e7eaf"
url: "https://pub.dev"
source: hosted
version: "0.53.6"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
slang:
dependency: transitive
description:
name: slang
sha256: "46e929158c2f563994c4d1fce5819cfa13e18b164941473d2553bcddcf387c31"
url: "https://pub.dev"
source: hosted
version: "4.15.0"
slang_flutter:
dependency: transitive
description:
name: slang_flutter
sha256: "0eb6348416a296f1bd940fe02669bcd2df5c5cfdabf893b98e448df8b7ecf4ac"
url: "https://pub.dev"
source: hosted
version: "4.15.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
theme_extensions_builder_annotation:
dependency: transitive
description:
name: theme_extensions_builder_annotation
sha256: "75f28ac85d396d143d111a47c1395b01f3be41b7135f37bd51512921944e4206"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
two_dimensional_scrollables:
dependency: transitive
description:
name: two_dimensional_scrollables
sha256: "4f25bd42783626c5f2810333418727455397195acb61e53710e638a6a98e0e5e"
url: "https://pub.dev"
source: hosted
version: "0.5.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
universal_image:
dependency: transitive
description:
name: universal_image
sha256: "2eae13df84a47960cc4148fec88f09031ea64cbf667b4131ff2c907dd7b7c6d1"
url: "https://pub.dev"
source: hosted
version: "1.0.12"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
url: "https://pub.dev"
source: hosted
version: "1.2.3"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.6"
+3
View File
@@ -0,0 +1,3 @@
RNB_DATABASE_URL=mysql+asyncmy://<db-user>:<db-pass>@<db-host>:<db-port>/report_notebooklm
RNB_REDIS_URL=redis://<host>:<port>/0
RNB_REDIS_KEY_PREFIX=rnb:
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
.pytest_cache/
.mypy_cache/
*.egg-info/
*.pyc
*.db
.env
.DS_Store
build/
*.apk
+56
View File
@@ -0,0 +1,56 @@
# report-notebooklm-api Notes
This file keeps short engineering notes for this repository. The durable handoff is in `docs/`.
## 2026-06-03 Phase 1 Scaffold
- Started the Phase 1 backend scaffold from `phase1-build-brief.md`.
- Technical identifiers use `report-notebooklm` / `rnb`; user-facing product name is `研听`.
- Implemented FastAPI config, database, cache helper, routers, SQLAlchemy models, Alembic migration, seed importer, and public read API.
- Public API prefix is `/api/report-notebooklm/v1`.
- Implemented public routes:
- `/health`
- `/feed/recommended`
- `/reports`
- `/reports/{id}`
- `/reports/{id}/modules/{module_id}`
- `/institutions`
- `/institutions/{id}`
- `/listen`
- Seed importer covers institutions, reports, display artifacts, display modules, audio assets, users, favorites, and playback progress.
- Heavy modules store preview/full content in a JSON envelope. Public detail responses expose previews for `card_plus_page`; the module endpoint exposes full content.
- Review-only modules do not appear in public responses.
- Public responses expose `cache_version`; `display_version` and module `version` remain internal.
## Verification Snapshot
- Backend tests: `pytest -q` passed.
- Local API smoke checks passed for `/health`, `/feed/recommended`, and `/reports/rep_ssga_gold`.
- Companion App analyze/test/build checks passed when using a Flutter SDK compatible with Dart 3.12.1.
- Android debug validation was completed during local handoff. Build artifacts and screenshots are transient and should not be committed.
## Resolved Product Decisions
- Public responses expose only `cache_version`.
- Heavy module access keeps both `content_ref` and `GET /reports/{id}/modules/{module_id}` available.
- Public published content may use direct content references; restricted sources should use backend short-lived signed URLs.
- FAQ, Study Guide, and Glossary are represented as a single `study_guide` module type.
- `faq` stays deprecated; legacy seed `faq` should map to `study_guide`.
- Gray-source full-text audio is allowed by product decision but still needs operations/compliance review before production release.
- App prototype feedback decisions from 2026-06-03 are durable in mall-docs `docs/2026-06-03-app-prototype-feedback-decisions.md`.
- Seed/display module order is: 报告概览 / 报告摘要 / 听研报 / 报告要点 / 报告中的关键数据 / 观点差异 / 局限与疑问 / 时间线 / 术语与问答 / 结构梳理 / 延伸阅读 / 报告来源.
- Do not seed a separate `institution` display module for public Detail. Publisher information belongs inside the source/compliance surface rendered as `报告来源`.
- The real BIS sample should be the top report, but public UI copy must not expose internal labels such as NotebookLM sample, query artifact, or artifact mapping.
- `basic_info` and `executive_overview` must not repeat the same text: overview is factual scope/metadata; summary is a few-sentence report-level description.
- All public modules returned for Detail should expose `has_detail_page=True`; tests assert this to prevent accidental regression.
## Remaining Backend Gaps
- Auth routes.
- Personal-state routes.
- Audio stream signed URL route.
- Outbound events route.
- Internal management routes.
- Production object storage integration.
- Production cache invalidation and pagination.
- Deployment environment configuration.
+62
View File
@@ -0,0 +1,62 @@
# report-notebooklm-api
report-notebooklm 第一阶段对外只读接口的 FastAPI 服务。
这个目录是 API、数据模型、种子数据导入,以及由 NotebookLM 支撑的内容流水线的主要工程交接入口。配套的 Flutter 应用在同一个 monorepo 的 `../report-notebooklm-app/` 里。
## 先读这些
- [docs/HANDOFF.md](docs/HANDOFF.md):当前进度、已解决的问题、待解决的问题,以及交接顺序。
- [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md):产品和第一阶段范围速览。
- [docs/API_AND_DATA.md](docs/API_AND_DATA.md):数据表、接口,以及已实现 / 计划中的 API。
- [docs/CONTENT_PIPELINE.md](docs/CONTENT_PIPELINE.md):研报来源和 NotebookLM 产物的流转。
- [docs/RUNBOOK.md](docs/RUNBOOK.md):本地搭建、种子数据导入、冒烟检查和部署检查。
- [docs/ROADMAP_AND_OPEN_ISSUES.md](docs/ROADMAP_AND_OPEN_ISSUES.md):下一步的工程工作。
- [docs/SOURCE_INDEX.md](docs/SOURCE_INDEX.md):本次交接快照所用到的源文档名称。
## 产品边界
这个仓库装的是代码和一份工程交接快照,不是产品的唯一真源。
产品 SSOTmall-docs 里的 report-notebooklm 文档。快照日期:2026-06-03。
技术标识符用 `report-notebooklm``rnb`,面向用户的产品名是 `研听`
## 本地快速上手
按你环境里可用的后端服务,创建一个 `.env` 文件:
```bash
RNB_DATABASE_URL=mysql+asyncmy://<db-user>:<db-pass>@<db-host>:<db-port>/report_notebooklm
RNB_REDIS_URL=redis://<host>:<port>/0
RNB_REDIS_KEY_PREFIX=rnb:
```
然后运行:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
alembic upgrade head
python scripts/import_seed_content.py
uvicorn app.main:app --reload --host <bind-host> --port <port>
```
API 前缀:`/api/report-notebooklm/v1`
## 验证
```bash
source .venv/bin/activate
pytest -q
```
服务启动后,建议做这些冒烟检查:
```bash
API_BASE_URL=http://<api-host>:<port>/api/report-notebooklm/v1
curl "$API_BASE_URL/health"
curl "$API_BASE_URL/feed/recommended"
curl "$API_BASE_URL/reports/rep_ssga_gold"
```
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = migrations
prepend_sys_path = .
path_separator = os
# Runtime value is injected from RNB_DATABASE_URL in migrations/env.py.
sqlalchemy.url = sqlite+aiosqlite:///unused_alembic_config.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
+15
View File
@@ -0,0 +1,15 @@
from redis.asyncio import Redis
from app.config import get_settings
settings = get_settings()
def prefixed_key(key: str) -> str:
return f"{settings.redis_key_prefix}{key}"
def get_redis() -> Redis:
return Redis.from_url(settings.redis_url, decode_responses=True)
+18
View File
@@ -0,0 +1,18 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "report-notebooklm-api"
api_prefix: str = "/api/report-notebooklm/v1"
database_url: str
redis_url: str
redis_key_prefix: str = "rnb:"
model_config = SettingsConfigDict(env_prefix="RNB_", env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
+22
View File
@@ -0,0 +1,22 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.routers import health, institutions, listen, reports
settings = get_settings()
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["*"],
)
app.include_router(health.router, prefix=settings.api_prefix)
app.include_router(reports.router, prefix=settings.api_prefix)
app.include_router(institutions.router, prefix=settings.api_prefix)
app.include_router(listen.router, prefix=settings.api_prefix)
@@ -0,0 +1,32 @@
from app.models.entities import (
AudioAsset,
DisplayArtifact,
DisplayModule,
Favorite,
Institution,
OutboundEvent,
PlaybackProgress,
RawArtifact,
ReadingHistory,
RelatedNews,
Report,
SavedListen,
User,
)
__all__ = [
"AudioAsset",
"DisplayArtifact",
"DisplayModule",
"Favorite",
"Institution",
"OutboundEvent",
"PlaybackProgress",
"RawArtifact",
"ReadingHistory",
"RelatedNews",
"Report",
"SavedListen",
"User",
]
@@ -0,0 +1,302 @@
from __future__ import annotations
import datetime as dt
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.mysql import MEDIUMTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def utcnow() -> dt.datetime:
return dt.datetime.now(dt.UTC).replace(tzinfo=None)
MediumText = Text().with_variant(MEDIUMTEXT, "mysql")
class Institution(Base):
__tablename__ = "institutions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
institution_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
name_cn: Mapped[str] = mapped_column(String(255), nullable=False)
name_en: Mapped[str | None] = mapped_column(String(255))
institution_type: Mapped[str] = mapped_column(String(32), nullable=False)
source_tier: Mapped[str] = mapped_column(String(16), nullable=False)
website_url: Mapped[str | None] = mapped_column(String(512))
covered_topics: Mapped[str | None] = mapped_column(Text)
intro_cn: Mapped[str | None] = mapped_column(Text)
credibility_note: Mapped[str | None] = mapped_column(Text)
report_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
latest_report_id: Mapped[str | None] = mapped_column(String(64))
latest_report_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
reports: Mapped[list[Report]] = relationship(back_populates="institution")
__table_args__ = (Index("ix_institutions_status_latest", "status", "latest_report_at"),)
class Report(Base):
__tablename__ = "reports"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
report_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_type: Mapped[str] = mapped_column(String(16), nullable=False, default="single")
title_cn: Mapped[str] = mapped_column(String(512), nullable=False)
subtitle_cn: Mapped[str | None] = mapped_column(String(512))
original_title: Mapped[str | None] = mapped_column(String(512))
one_liner: Mapped[str | None] = mapped_column(String(512))
institution_id: Mapped[str] = mapped_column(String(64), ForeignKey("institutions.institution_id"), nullable=False)
co_institution_ids: Mapped[str | None] = mapped_column(Text)
source_tier: Mapped[str] = mapped_column(String(32), nullable=False)
source_url: Mapped[str | None] = mapped_column(String(512))
source_note: Mapped[str] = mapped_column(Text, nullable=False)
published_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
interpreted_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
released_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
topics: Mapped[str | None] = mapped_column(Text)
language: Mapped[str] = mapped_column(String(8), nullable=False, default="en")
has_audio: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
display_status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
display_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cache_version: Mapped[str] = mapped_column(String(128), nullable=False)
risk_disclaimer: Mapped[str | None] = mapped_column(Text)
interpretation_label: Mapped[str | None] = mapped_column(String(64), default="研报解读")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
institution: Mapped[Institution] = relationship(back_populates="reports")
__table_args__ = (
Index("ix_reports_status_released", "display_status", "released_at"),
Index("ix_reports_institution_released", "institution_id", "released_at"),
Index("ix_reports_audio_released", "has_audio", "released_at"),
)
class RawArtifact(Base):
__tablename__ = "raw_artifacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
raw_artifact_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False, default="notebooklm")
artifact_type: Mapped[str] = mapped_column(String(64), nullable=False)
conversation_id: Mapped[str | None] = mapped_column(String(128))
source_id: Mapped[str | None] = mapped_column(String(128))
notebook_id: Mapped[str | None] = mapped_column(String(128))
source_language: Mapped[str | None] = mapped_column(String(8))
payload_format: Mapped[str] = mapped_column(String(16), nullable=False)
payload_ref: Mapped[str | None] = mapped_column(String(512))
sha256: Mapped[str | None] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
error: Mapped[str | None] = mapped_column(Text)
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
generated_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
ingested_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
is_publish_blocking: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
requires_human_review: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
quality_flags: Mapped[str | None] = mapped_column(Text)
retention_status: Mapped[str] = mapped_column(String(32), nullable=False, default="retained")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
__table_args__ = (
Index("ix_raw_report_type", "report_id", "artifact_type"),
Index("ix_raw_report_status", "report_id", "status"),
Index("ix_raw_retention", "retention_status"),
)
class DisplayArtifact(Base):
__tablename__ = "display_artifacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
display_artifact_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
display_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
title_cn: Mapped[str] = mapped_column(String(512), nullable=False)
summary_cn: Mapped[str | None] = mapped_column(Text)
source_label: Mapped[str | None] = mapped_column(String(255))
interpretation_label: Mapped[str | None] = mapped_column(String(64), default="研报解读")
ai_generated_label: Mapped[str | None] = mapped_column(String(128))
synthesis_type: Mapped[str | None] = mapped_column(String(16))
source_disclosure_text: Mapped[str | None] = mapped_column(Text)
review_status: Mapped[str] = mapped_column(String(16), nullable=False, default="review")
reviewed_by: Mapped[str | None] = mapped_column(String(128))
reviewed_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
published_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
__table_args__ = (Index("ix_display_artifacts_report_status_version", "report_id", "review_status", "display_version"),)
class DisplayModule(Base):
__tablename__ = "display_modules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
module_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
display_artifact_id: Mapped[str] = mapped_column(String(64), ForeignKey("display_artifacts.display_artifact_id"), nullable=False)
type: Mapped[str] = mapped_column(String(32), nullable=False)
title_cn: Mapped[str | None] = mapped_column(String(255))
content_format: Mapped[str] = mapped_column(String(16), nullable=False)
content: Mapped[str | None] = mapped_column(MediumText)
content_ref: Mapped[str | None] = mapped_column(String(512))
content_etag: Mapped[str | None] = mapped_column(String(64))
source_raw_artifact_ids: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="missing")
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
__table_args__ = (
Index("ix_display_modules_report_status_sort", "report_id", "status", "sort_order"),
Index("ix_display_modules_artifact_status", "display_artifact_id", "status"),
)
class AudioAsset(Base):
__tablename__ = "audio_assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
audio_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
source_raw_artifact_id: Mapped[str | None] = mapped_column(String(64), ForeignKey("raw_artifacts.raw_artifact_id"))
title_cn: Mapped[str] = mapped_column(String(512), nullable=False)
duration_sec: Mapped[int | None] = mapped_column(Integer)
oss_key: Mapped[str | None] = mapped_column(String(512))
waveform_ref: Mapped[str | None] = mapped_column(String(512))
chapters: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="missing")
published_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
__table_args__ = (Index("ix_audio_report_status", "report_id", "status"),)
class RelatedNews(Base):
__tablename__ = "related_news"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
related_news_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
title: Mapped[str] = mapped_column(String(512), nullable=False)
source_name: Mapped[str | None] = mapped_column(String(255))
source_url: Mapped[str | None] = mapped_column(String(512))
published_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
language: Mapped[str | None] = mapped_column(String(8))
summary_cn: Mapped[str | None] = mapped_column(Text)
match_method: Mapped[str] = mapped_column(String(32), nullable=False, default="manual_curated")
match_keywords: Mapped[str | None] = mapped_column(Text)
match_confidence: Mapped[str | None] = mapped_column(String(8))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="candidate")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
__table_args__ = (Index("ix_related_news_report_status", "report_id", "status"),)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
phone_hash: Mapped[str | None] = mapped_column(String(128), unique=True)
wechat_openid: Mapped[str | None] = mapped_column(String(128), unique=True)
apple_user_id: Mapped[str | None] = mapped_column(String(256), unique=True)
display_name: Mapped[str | None] = mapped_column(String(128))
avatar_url: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
last_login_at: Mapped[dt.datetime | None] = mapped_column(DateTime)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
class Favorite(Base):
__tablename__ = "favorites"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
favorite_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), ForeignKey("users.user_id"), nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
__table_args__ = (UniqueConstraint("user_id", "report_id", name="uq_favorites_user_report"), Index("ix_favorites_user_report", "user_id", "report_id"))
class ReadingHistory(Base):
__tablename__ = "reading_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
history_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), ForeignKey("users.user_id"), nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
event_type: Mapped[str] = mapped_column(String(32), nullable=False, default="view_detail")
last_position: Mapped[str | None] = mapped_column(Text)
last_seen_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
__table_args__ = (Index("ix_reading_history_user_seen", "user_id", "last_seen_at"), Index("ix_reading_history_user_report", "user_id", "report_id"))
class SavedListen(Base):
__tablename__ = "saved_listens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
saved_listen_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), ForeignKey("users.user_id"), nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
audio_id: Mapped[str] = mapped_column(String(64), ForeignKey("audio_assets.audio_id"), nullable=False)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
__table_args__ = (UniqueConstraint("user_id", "audio_id", name="uq_saved_listens_user_audio"),)
class PlaybackProgress(Base):
__tablename__ = "playback_progress"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
progress_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(64), ForeignKey("users.user_id"), nullable=False)
audio_id: Mapped[str] = mapped_column(String(64), ForeignKey("audio_assets.audio_id"), nullable=False)
report_id: Mapped[str] = mapped_column(String(64), ForeignKey("reports.report_id"), nullable=False)
position_sec: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
duration_sec: Mapped[int | None] = mapped_column(Integer)
completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
__table_args__ = (UniqueConstraint("user_id", "audio_id", name="uq_playback_user_audio"), Index("ix_playback_user_audio", "user_id", "audio_id"))
class OutboundEvent(Base):
__tablename__ = "outbound_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
outbound_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
click_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
tracking_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[str | None] = mapped_column(String(64), ForeignKey("users.user_id"))
device_id: Mapped[str | None] = mapped_column(String(128))
report_id: Mapped[str | None] = mapped_column(String(64), ForeignKey("reports.report_id"))
institution_id: Mapped[str | None] = mapped_column(String(64))
scene: Mapped[str | None] = mapped_column(String(64))
ref: Mapped[str | None] = mapped_column(String(128))
target: Mapped[str | None] = mapped_column(String(32))
source_page: Mapped[str | None] = mapped_column(String(32))
placement: Mapped[str | None] = mapped_column(String(64))
campaign_id: Mapped[str | None] = mapped_column(String(64))
target_app: Mapped[str | None] = mapped_column(String(64))
commodity_tag: Mapped[str | None] = mapped_column(String(64))
hook_type: Mapped[str | None] = mapped_column(String(64))
user_state: Mapped[str | None] = mapped_column(String(16))
ts: Mapped[int | None] = mapped_column(BigInteger)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
__table_args__ = (Index("ix_outbound_tracking", "tracking_id"), Index("ix_outbound_report_created", "report_id", "created_at"))
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,9 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.services.catalog import CatalogService
router = APIRouter()
@router.get("/institutions")
async def institutions(
topic: str | None = None,
source_tier: str | None = None,
page_size: int = Query(20, ge=1, le=50),
session: AsyncSession = Depends(get_session),
) -> dict:
return await CatalogService(session).institutions(topic=topic, source_tier=source_tier, page_size=page_size)
@router.get("/institutions/{institution_id}")
async def institution_detail(institution_id: str, session: AsyncSession = Depends(get_session)) -> dict:
return await CatalogService(session).institution_detail(institution_id)
@@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.services.catalog import CatalogService
router = APIRouter()
@router.get("/listen")
async def listen(page_size: int = Query(20, ge=1, le=50), session: AsyncSession = Depends(get_session)) -> dict:
return await CatalogService(session).listen_items(page_size=page_size)
@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.services.catalog import CatalogService
router = APIRouter()
@router.get("/feed/recommended")
async def recommended_feed(
topic: str | None = None,
page_size: int = Query(20, ge=1, le=50),
session: AsyncSession = Depends(get_session),
) -> dict:
return await CatalogService(session).report_cards(topic=topic, page_size=page_size)
@router.get("/reports")
async def reports(
topic: str | None = None,
institution_id: str | None = None,
has_audio: bool | None = None,
source_tier: str | None = None,
q: str | None = None,
page_size: int = Query(20, ge=1, le=50),
session: AsyncSession = Depends(get_session),
) -> dict:
return await CatalogService(session).report_cards(
topic=topic,
institution_id=institution_id,
has_audio=has_audio,
source_tier=source_tier,
q=q,
page_size=page_size,
)
@router.get("/reports/{report_id}")
async def report_detail(report_id: str, session: AsyncSession = Depends(get_session)) -> dict:
return await CatalogService(session).report_detail(report_id)
@router.get("/reports/{report_id}/modules/{module_id}")
async def module_detail(report_id: str, module_id: str, session: AsyncSession = Depends(get_session)) -> dict:
return await CatalogService(session).module_detail(report_id, module_id)
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,272 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import HTTPException
from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AudioAsset, DisplayModule, Institution, RelatedNews, Report
MODULE_META: dict[str, dict[str, Any]] = {
"basic_info": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"executive_overview": {"layer": "p0", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"core_insights": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"key_data": {"layer": "p0", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"source_compliance": {"layer": "p0", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": True, "requires_human_review": False},
"differentiated_view": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"weaknesses": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"timeline": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"study_guide": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"related_sources": {"layer": "p1", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
"structure_graph": {"layer": "p1", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"infographic": {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
"audio": {"layer": "p2", "render_mode": "inline", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False},
"research_discovery": {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": True},
"institution": {"layer": "p0", "render_mode": "inline", "has_detail_page": False, "is_publish_blocking": False, "requires_human_review": False},
}
def loads_json(value: str | None, default: Any) -> Any:
if not value:
return default
return json.loads(value)
def iso(value: Any) -> str | None:
return value.isoformat() if value else None
def institution_public(inst: Institution, *, detail: bool = False) -> dict[str, Any]:
data = {
"institution_id": inst.institution_id,
"name_cn": inst.name_cn,
"name_en": inst.name_en,
"institution_type": inst.institution_type,
"source_tier": inst.source_tier,
"website_url": inst.website_url,
"covered_topics": loads_json(inst.covered_topics, []),
"report_count": inst.report_count,
"latest_report_at": iso(inst.latest_report_at),
"credibility_note": inst.credibility_note,
}
if detail:
data["intro_cn"] = inst.intro_cn
return data
def institution_card(inst: Institution) -> dict[str, Any]:
return {
"institution_id": inst.institution_id,
"name_cn": inst.name_cn,
"name_en": inst.name_en,
"source_tier": inst.source_tier,
}
def report_card(report: Report, inst: Institution) -> dict[str, Any]:
return {
"report_id": report.report_id,
"title_cn": report.title_cn,
"subtitle_cn": report.subtitle_cn or "",
"one_liner": report.one_liner,
"institution": institution_card(inst),
"topics": loads_json(report.topics, []),
"released_at": iso(report.released_at),
"has_audio": report.has_audio,
"interpretation_label": report.interpretation_label,
"source_tier": report.source_tier,
"cache_version": report.cache_version,
}
def module_payload(module: DisplayModule) -> dict[str, Any]:
meta = MODULE_META.get(module.type, {"layer": "p2", "render_mode": "card_plus_page", "has_detail_page": True, "is_publish_blocking": False, "requires_human_review": False})
envelope = loads_json(module.content, {})
render_mode = meta["render_mode"]
content = None
preview = None
if render_mode == "inline":
content = envelope.get("content", envelope)
else:
preview = envelope.get("preview", {})
return {
"module_id": module.module_id,
"type": module.type,
"layer": meta["layer"],
"render_mode": render_mode,
"has_detail_page": meta["has_detail_page"],
"is_publish_blocking": meta["is_publish_blocking"],
"requires_human_review": meta["requires_human_review"],
"sort_order": module.sort_order,
"title_cn": module.title_cn,
"content": content,
"preview": preview,
"content_ref": module.content_ref,
"content_etag": module.content_etag,
}
class CatalogService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def _published_report_query(self) -> Select[tuple[Report, Institution]]:
return (
select(Report, Institution)
.join(Institution, Report.institution_id == Institution.institution_id)
.where(Report.display_status == "published", Institution.status == "active")
.order_by(Report.released_at.desc(), Report.report_id)
)
async def report_cards(
self,
*,
topic: str | None = None,
institution_id: str | None = None,
has_audio: bool | None = None,
source_tier: str | None = None,
q: str | None = None,
page_size: int = 20,
) -> dict[str, Any]:
stmt = await self._published_report_query()
if topic:
stmt = stmt.where(Report.topics.like(f"%{topic}%"))
if institution_id:
stmt = stmt.where(Report.institution_id == institution_id)
if has_audio is not None:
stmt = stmt.where(Report.has_audio == has_audio)
if source_tier:
stmt = stmt.where(Report.source_tier == source_tier)
if q:
stmt = stmt.where(Report.title_cn.like(f"%{q}%"))
stmt = stmt.limit(min(max(page_size, 1), 50))
rows = (await self.session.execute(stmt)).all()
return {
"items": [report_card(report, inst) for report, inst in rows],
"page": {"next_cursor": None, "has_more": False},
"cache_version": "feed:recommended:seed:v1",
}
async def report_detail(self, report_id: str) -> dict[str, Any]:
row = (
await self.session.execute(
select(Report, Institution)
.join(Institution, Report.institution_id == Institution.institution_id)
.where(Report.report_id == report_id, Report.display_status == "published", Institution.status == "active")
)
).one_or_none()
if row is None:
raise HTTPException(status_code=404, detail={"error": {"code": "REPORT_NOT_FOUND", "message": "报告不存在或未发布。"}})
report, inst = row
modules = (
await self.session.execute(
select(DisplayModule)
.where(DisplayModule.report_id == report_id, DisplayModule.status == "published")
.order_by(DisplayModule.sort_order)
)
).scalars().all()
return {
"report_id": report.report_id,
"title_cn": report.title_cn,
"subtitle_cn": report.subtitle_cn or "",
"original_title": report.original_title,
"one_liner": report.one_liner,
"institution": institution_public(inst, detail=True),
"source": {
"source_url": report.source_url,
"source_note": report.source_note,
"source_tier": report.source_tier,
"published_at": iso(report.published_at),
},
"topics": loads_json(report.topics, []),
"has_audio": report.has_audio,
"interpretation_label": report.interpretation_label,
"risk_disclaimer": report.risk_disclaimer,
"released_at": iso(report.released_at),
"cache_version": report.cache_version,
"modules": [module_payload(module) for module in modules],
}
async def module_detail(self, report_id: str, module_id: str) -> dict[str, Any]:
report = (
await self.session.execute(select(Report).where(Report.report_id == report_id, Report.display_status == "published"))
).scalar_one_or_none()
if report is None:
raise HTTPException(status_code=404, detail={"error": {"code": "REPORT_NOT_FOUND", "message": "报告不存在或未发布。"}})
module = (
await self.session.execute(
select(DisplayModule).where(DisplayModule.report_id == report_id, DisplayModule.module_id == module_id, DisplayModule.status == "published")
)
).scalar_one_or_none()
if module is None:
raise HTTPException(status_code=404, detail={"error": {"code": "MODULE_HIDDEN", "message": "模块隐藏或不可见。"}})
envelope = loads_json(module.content, {})
content = envelope.get("full") or envelope.get("content") or envelope
return {
"module_id": module.module_id,
"type": module.type,
"title_cn": module.title_cn,
"content": content,
"content_etag": module.content_etag,
"cache_version": report.cache_version,
}
async def institutions(self, *, topic: str | None = None, source_tier: str | None = None, page_size: int = 20) -> dict[str, Any]:
stmt = select(Institution).where(Institution.status == "active").order_by(Institution.source_tier, Institution.name_cn).limit(min(max(page_size, 1), 50))
if topic:
stmt = stmt.where(Institution.covered_topics.like(f"%{topic}%"))
if source_tier:
stmt = stmt.where(Institution.source_tier == source_tier)
rows = (await self.session.execute(stmt)).scalars().all()
return {"items": [institution_public(inst) for inst in rows], "page": {"next_cursor": None, "has_more": False}}
async def institution_detail(self, institution_id: str) -> dict[str, Any]:
inst = (await self.session.execute(select(Institution).where(Institution.institution_id == institution_id, Institution.status == "active"))).scalar_one_or_none()
if inst is None:
raise HTTPException(status_code=404, detail={"error": {"code": "INSTITUTION_NOT_FOUND", "message": "机构不存在。"}})
reports = await self.report_cards(institution_id=institution_id, page_size=5)
detail = institution_public(inst, detail=True)
detail["latest_report"] = reports["items"][0] if reports["items"] else None
detail["recent_reports"] = reports["items"]
return detail
async def listen_items(self, *, page_size: int = 20) -> dict[str, Any]:
stmt = (
select(AudioAsset, Report, Institution)
.join(Report, AudioAsset.report_id == Report.report_id)
.join(Institution, Report.institution_id == Institution.institution_id)
.where(AudioAsset.status == "published", Report.display_status == "published")
.order_by(Report.released_at.desc(), AudioAsset.audio_id)
.limit(min(max(page_size, 1), 50))
)
rows = (await self.session.execute(stmt)).all()
items = [
{
"audio_id": audio.audio_id,
"title_cn": audio.title_cn,
"duration_sec": audio.duration_sec,
"report_id": report.report_id,
"report_title_cn": report.title_cn,
"institution": institution_card(inst),
"released_at": iso(report.released_at),
"cache_version": report.cache_version,
}
for audio, report, inst in rows
]
return {"items": items, "page": {"next_cursor": None, "has_more": False}, "cache_version": "listen:seed:v1"}
async def seed_counts(self) -> dict[str, int]:
models = {
"institutions": Institution,
"reports": Report,
"audio_assets": AudioAsset,
"display_modules": DisplayModule,
"related_news": RelatedNews,
}
counts = {}
for name, model in models.items():
counts[name] = await self.session.scalar(select(func.count()).select_from(model)) or 0
return counts
+185
View File
@@ -0,0 +1,185 @@
# API and Data Handoff
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Current Implementation Status
Implemented in this repository:
- FastAPI app under `/api/report-notebooklm/v1`.
- SQLAlchemy model layer for the Phase 1 table set.
- Alembic initial migration.
- Seed import script with institutions, reports, modules, audio assets, users, favorites, and playback-progress fixtures.
- Public read endpoints for health, feeds, reports, modules, institutions, and listen list.
- Tests covering seed counts, public response shape, module visibility, gray-source handling, and listen behavior.
Not implemented yet:
- Auth APIs.
- Personal state APIs.
- Audio stream signing endpoint.
- Outbound events endpoint.
- Internal management APIs.
- Real Redis cache invalidation policy.
- Real object-storage signed URL policy.
- Production pagination/cursor behavior beyond seed-scale responses.
## Data Tables
| Table | Purpose | Current model |
|---|---|---|
| `institutions` | Institution profile, source tier, website, topics, credibility notes. | Implemented |
| `reports` | Report master record, source, topics, publication state, cache version. | Implemented |
| `raw_artifacts` | NotebookLM artifact metadata and object-storage references. | Implemented as metadata only |
| `display_artifacts` | Reviewed display version metadata for App consumption. | Implemented |
| `display_modules` | Detail-page modules, sort order, visibility, content or content reference. | Implemented |
| `audio_assets` | Audio metadata and object-storage key. | Implemented |
| `related_news` | Related-source candidates and reviewed related items. | Implemented |
| `users` | User account records. | Implemented as seed model, no auth routes |
| `favorites` | User report favorites. | Implemented as seed model, no API routes |
| `reading_history` | User reading/history events. | Implemented as model, no API routes |
| `saved_listens` | User saved-listen records. | Implemented as model, no API routes |
| `playback_progress` | Playback progress sync records. | Implemented as seed model, no API routes |
| `outbound_events` | External attribution events. | Implemented as model, no API route |
## Public API Implemented
Prefix: `/api/report-notebooklm/v1`
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/health` | Service health. |
| `GET` | `/feed/recommended` | Published report cards for recommendation feed. |
| `GET` | `/reports` | Published report cards with basic filters. |
| `GET` | `/reports/{report_id}` | Report detail skeleton and published modules. |
| `GET` | `/reports/{report_id}/modules/{module_id}` | Full content for a visible module. |
| `GET` | `/institutions` | Active institution list. |
| `GET` | `/institutions/{institution_id}` | Institution detail with latest/recent reports. |
| `GET` | `/listen` | Published audio-backed report list. |
Current filters:
- `/reports`: `topic`, `institution_id`, `has_audio`, `source_tier`, `q`, `page_size`.
- `/institutions`: `topic`, `source_tier`, `page_size`.
- `/feed/recommended` and `/listen`: `page_size`.
Current pagination is seed-scale. Responses return `next_cursor: null` and `has_more: false`.
## Planned Public API
The Phase 1 contract also expects:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/audio/{audio_id}/stream` | Return short-lived playable URL. |
| `POST` | `/outbound/events` | Persist external attribution click event. |
Audio stream must not return a permanent object-storage URL. The planned behavior is backend-signed short-lived playback URL with no download URL.
## Planned Auth and Personal State API
Auth:
- `POST /auth/phone/start`
- `POST /auth/phone/verify`
- `POST /auth/wechat`
- `POST /auth/apple`
Personal state:
- `GET /me`
- `GET /me/favorites`
- `POST /me/favorites`
- `DELETE /me/favorites/{report_id}`
- `GET /me/history`
- `POST /me/history`
- `GET /me/listens/saved`
- `POST /me/listens/saved`
- `DELETE /me/listens/saved/{audio_id}`
- `POST /me/playback-progress`
- `GET /me/playback-progress/{audio_id}`
These endpoints are contract-level requirements but are not implemented in this scaffold.
## Planned Internal API
Internal APIs should require service token and network allowlist. They must never be exposed to the App.
- `POST /internal/reports`
- `POST /internal/reports/{report_id}/raw-artifacts`
- `GET /internal/reports/{report_id}/raw-artifacts`
- `POST /internal/reports/{report_id}/display-artifacts`
- `PATCH /internal/modules/{module_id}`
- `POST /internal/reports/{report_id}/publish`
- `POST /internal/reports/{report_id}/hide`
- `POST /internal/related-news/candidates`
Publishing should update report display status, update `has_audio`, bump `cache_version`, and clear related cache keys.
## Public vs Internal Fields
Public responses may expose:
- Report identity, title, subtitle, one-liner, topics, institution card, release time, source tier, interpretation label, `has_audio`, and `cache_version`.
- Detail source note, source URL where allowed, risk disclaimer, and published display modules.
- Module metadata needed by the client: `module_id`, `type`, `layer`, `render_mode`, `has_detail_page`, `is_publish_blocking`, `requires_human_review`, `sort_order`, `title_cn`, `content`, `preview`, `content_ref`, `content_etag`.
Public responses must not expose:
- Raw artifact payload.
- Object-storage private paths for raw artifacts.
- NotebookLM notebook IDs, source IDs, conversation IDs, or local account information.
- Local filesystem paths.
- `display_version` or `module.version`.
- User phone hash, WeChat OpenID, Apple user ID, or auth internals.
The public cache contract is a single `cache_version` string. `display_version` and module `version` are server-internal fields only.
## Seed Data
The seed importer currently creates:
- 18 institutions.
- 27 reports, including one NotebookLM sample report and multiple boundary cases.
- 15 audio assets.
- More than 120 display modules.
- Test users, favorites, and playback progress.
Seed boundary cases intentionally cover:
- Reports with audio and reports without audio.
- Hidden/unpublished report behavior.
- Gray broker source with restricted source URL behavior.
- Published modules vs review-only modules.
- `study_guide` module replacing legacy `faq`.
- Heavy modules using `card_plus_page` preview plus full-module endpoint.
Do not treat seed content as production content. It exists to exercise app/API behavior and edge cases.
## Detail Module Model
The detail page uses a skeleton plus module model:
- Inline modules include small `content` directly in the detail response.
- Heavy modules use `render_mode=card_plus_page`, return `preview` in detail, and load full content from `/reports/{report_id}/modules/{module_id}`.
- Unknown future module types should not break the App; they should fall back to hidden or generic rendering.
Core module types:
- `basic_info`
- `executive_overview`
- `core_insights`
- `key_data`
- `source_compliance`
- `institution`
- `differentiated_view`
- `weaknesses`
- `timeline`
- `study_guide`
- `structure_graph`
- `related_sources`
- `infographic`
- `audio`
- `research_discovery`
@@ -0,0 +1,142 @@
# Content Pipeline Handoff
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Content Principle
Use NotebookLM as a source-driven research engine, not as a generic rewriting model.
The pipeline may orchestrate, clean, validate, map, and review NotebookLM-native artifacts. It must not silently replace missing NotebookLM artifacts with locally rewritten publishable content.
## Source Inputs
Phase 1 content is based on public or authorized institutional research reports. Priority source categories:
- Official public sources.
- Authorized partner sources.
- Gray broker public sources, with stricter review and source display handling.
Vision source lists, tiering, and historical source-health experience may be used as reference material. Production data must not depend on a local Vision runtime, local path, local cache, or local account state.
## NotebookLM Workflow
Recommended report run order:
1. Inspect the source PDF: title, institution, date, page count, size, and report type.
2. Create or reuse one notebook for one report source unless a multi-report synthesis is explicitly planned.
3. Upload the report source.
4. Generate the P0 text package:
- source description
- native Briefing Doc
- native Blog Post
- data table
- query dimensions
- query key data
- query divergence
- query weaknesses
5. Generate useful P1 artifacts:
- query timeline
- query related sources
- Study Guide
- mind map, if download succeeds
6. Generate P2 artifacts asynchronously:
- infographic candidate
- audio brief
- research discovery
7. Persist every artifact status in a manifest.
8. Deterministically assemble display modules from reviewed artifacts.
9. Run human review before publishing.
## Artifact Types
The Phase 1 schema supports these NotebookLM artifact types:
| Artifact type | Purpose | Publish blocking | Human review |
|---|---|---:|---:|
| `source_summary` | Source-level summary. | No | No |
| `notebook_summary` | Notebook-level summary. | No | No |
| `native_briefing_doc` | Native briefing document. | Yes | No |
| `native_blog_post` | Native blog post. | Yes | No |
| `native_study_guide` | FAQ, study guide, glossary. | No | No |
| `data_table` | Structured table data. | Yes | No |
| `mind_map` | Mind map or graph source. | No | No |
| `query_dimensions` | Analysis dimensions. | Yes | No |
| `query_key_data` | Key data points. | Yes | No |
| `query_divergence` | Views that diverge from consensus. | No | No |
| `query_weaknesses` | Weaknesses and open questions. | No | No |
| `query_timeline` | Timeline and turning points. | No | No |
| `query_related_sources` | Related source candidates. | No | Yes |
| `research_discovery` | Enrichment queue. | No | Yes |
| `infographic` | Candidate public image. | No | Yes |
| `audio_brief` | Listening preview or audio source. | No | No |
Artifact records should keep status, object reference, format, size, hash, generated time, error, and review flags. Raw payloads should stay in object storage and remain internal.
## Module Mapping
| Product module | Primary artifact sources | Notes |
|---|---|---|
| `basic_info` | Source metadata and source summary. | P0, inline. |
| `executive_overview` | Briefing Doc and Blog Post. | P0, heavy card plus page. |
| `core_insights` | Briefing Doc and query dimensions. | P0, inline with optional detail page. |
| `key_data` | Data table and query key data. | P0, heavy card plus page. |
| `source_compliance` | Source metadata and review notes. | P0, inline, must include disclaimer. |
| `institution` | Institution record. | P0, inline. |
| `differentiated_view` | Query divergence. | P1, optional. |
| `weaknesses` | Query weaknesses. | P1, optional, avoid investment-advice wording. |
| `timeline` | Query timeline. | P1, optional. |
| `study_guide` | Native Study Guide. | P1, optional, replaces legacy `faq`. |
| `structure_graph` | Mind map or deterministic fallback. | P1, optional. |
| `related_sources` | Related-source query and review queue. | P1, review required before display. |
| `infographic` | Infographic candidate. | P2, review required before display. |
| `audio` | Audio brief or reviewed audio asset. | P2, not required for text publish. |
| `research_discovery` | Research discovery queue. | P2, internal or reviewed only. |
## Publish Gates
Blocking before public release:
- Source upload succeeded and is traceable.
- Required P0 text artifacts exist and have usable content.
- `basic_info`, `executive_overview`, `core_insights`, `key_data`, and `source_compliance` are present unless a product decision allows a partial report.
- Display artifact is reviewed and approved.
- Source attribution and risk disclaimer are present.
- No raw artifact payload, local path, private notebook ID, or account information appears in public responses.
Non-blocking:
- Mind map.
- Study guide.
- Timeline.
- Related-source candidates.
- Research discovery.
- Infographic.
- Audio.
If optional artifacts fail, record the failure and continue without inventing fallback public copy. Deterministic fallback is allowed for structure graph from already available artifacts.
## Cadence Notes
NotebookLM operations should be conservative by default:
- One active NotebookLM operation per account.
- Text artifacts first.
- Media artifacts after text success.
- Heavy media should not block publishable text.
- On transient failure, retry once; if an optional artifact fails again, mark it failed and continue.
The seed importer is not a production runner. A production runner should persist manifests after every operation and support resumable review/import.
## Human Review
Review is mandatory for:
- Gray broker sources.
- Related-source candidate display.
- Infographic or generated media.
- Any content where citations/page labels are ambiguous.
- Any copy that could be interpreted as investment advice.
Do not display raw NotebookLM page labels until they are normalized against verifiable source pages or sections.
+84
View File
@@ -0,0 +1,84 @@
# Backend Handoff
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Current State
The backend is a runnable Phase 1 scaffold for the public read surface. It is not production-ready yet.
Implemented:
- FastAPI app and API prefix.
- SQLAlchemy models for the Phase 1 table set.
- Alembic initial migration.
- Seed import script.
- Public read API for feed, reports, module detail, institutions, and listen list.
- Tests for current seed behavior and public response boundaries.
Not implemented:
- Authentication.
- User personal-state routes.
- Audio stream signing.
- Outbound attribution route.
- Internal management routes.
- Production object storage integration.
- Real Redis cache invalidation.
- Production deployment config.
## Repository Map
| Path | Purpose |
|---|---|
| `app/main.py` | FastAPI app, CORS, router registration. |
| `app/config.py` | Environment-driven settings. |
| `app/db.py` | Async SQLAlchemy engine and session dependency. |
| `app/cache.py` | Redis client helper and key prefixing. |
| `app/models/entities.py` | SQLAlchemy table models. |
| `app/routers/` | HTTP route handlers. |
| `app/services/catalog.py` | Public catalog response assembly. |
| `migrations/` | Alembic environment and migration files. |
| `scripts/import_seed_content.py` | Seed data importer and module fixture builder. |
| `tests/test_public_api.py` | Current API and seed behavior tests. |
| `docs/` | Engineering handoff documentation. |
## Solved Decisions
- Technical identifiers stay `report-notebooklm` / `rnb`; display name is `研听`.
- Public API responses expose `cache_version`, not `display_version` or module `version`.
- `study_guide` replaces legacy `faq`.
- Heavy modules use preview cards plus full-module endpoint.
- Raw artifacts stay internal; App consumes reviewed display artifacts only.
- Gray broker sources may be audio-ized only after the latest product decision and compliance review.
- Phase 1 has no interpretation-content download feature.
## Known Gaps
- `GET /audio/{audio_id}/stream` needs signed playback URL behavior.
- Auth and personal state APIs need implementation.
- `POST /outbound/events` needs implementation and validation for `click_id` / `tracking_id`.
- Internal publish/hide/import management endpoints need implementation.
- Cursor pagination and cache invalidation are seed-scale placeholders.
- Object storage policy needs a production decision for public vs signed module content.
- Release/deploy settings need staging and production environment values.
- Compliance must re-review gray-source audio and generated media rules before launch.
## Suggested Handoff Order
1. Read `docs/PROJECT_BRIEF.md`.
2. Read `docs/API_AND_DATA.md`.
3. Run the backend locally with seed data using `docs/RUNBOOK.md`.
4. Run `pytest -q` and smoke the three core public endpoints.
5. Pair with `report-notebooklm-app/` and verify `RNB_API_BASE` points to this service.
6. Choose the next work item from `docs/ROADMAP_AND_OPEN_ISSUES.md`.
## Definition of Done for Next Backend Work
- New API behavior has tests.
- Public responses do not expose internal/raw fields.
- Migrations include downgrade.
- New config is environment-driven.
- Seed data remains useful for App development.
- Documentation is updated when contract behavior changes.
@@ -0,0 +1,71 @@
# Project Brief Snapshot
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Product
`研听` is a Chinese research-report interpretation app for users who want to understand global institutional research with lower language and time barriers. It turns hard-to-read English research reports into structured Chinese reading and listening experiences.
Technical identifiers remain `report-notebooklm` and `rnb`. Do not use the product display name in code identifiers, database schema names, Redis keys, object storage paths, or API prefixes.
## Phase 1 Goals
- Validate whether Chinese users will repeatedly consume global institutional research-report interpretations.
- Ship a complete first app experience for discovery, reading, listening, saving, and returning to reports.
- Establish a minimum loop from report sources to selection, NotebookLM-assisted interpretation, review, storage, API distribution, and app display.
- Keep source attribution and compliance clear: this is report interpretation and annotation, not investment advice.
- Keep the commercial app independent from any local-only Vision runtime.
## Target Users
- General Chinese users interested in macro, precious metals, commodities, energy, central banks, and cross-asset research.
- Light professional users who want overseas institutional views and original-source traceability, without trading advice.
- Commuting or fragmented-time users who want reports transformed into listenable content.
Non-target users: professional terminal users, real-time trading-signal users, UGC/community users, and users expecting original investment recommendations.
## Main Tabs
| Tab | Phase 1 scope | Explicitly out of scope |
|---|---|---|
| 推荐 | Latest and curated report interpretations. | Ads, hard trading CTAs, real-time news flashes. |
| 研报 | All published report interpretations with basic filters. | Advanced investment terminal search. |
| 机构 | Institution list and institution report entry points. | Commercial institution ranking or onboarding backend. |
| 听单 | Reports that have audio form. | User-created podcasts, downloads, offline packages. |
| 我的 | Guest/login state, favorites, history, saved listening entry points. | Comments, UGC, paid membership, points. |
## Phase 1 Must Do
- Public browsing for recommended reports, report list, institutions, and listen list.
- Report detail pages with title, institution, publication/release data, source type, topics, summary, structured modules, source/compliance information, and favorite entry.
- Guest users can browse public content and fully listen to at least one episode.
- Logged-in users can synchronize favorites, reading history, saved listens, and playback progress.
- Published app responses must expose only reviewed display artifacts, not raw NotebookLM artifacts.
- Every report detail must preserve source attribution and risk disclaimer wording.
## Phase 1 Must Not Do
- No commercialization: no ads, paid unlock, membership, task wall, or points.
- No comments, community, UGC, or user-generated report interpretations.
- No investment advice, trading signals, buy/sell points, return promises, or portfolio recommendations.
- No original financial news, real-time reporting, or commentary positioned as original market views.
- No in-product downloads for interpretation content, audio packages, or PDFs.
- No long-term production dependency on a local Vision runtime, local SQLite, local scripts, local paths, or local account state.
- No App or server-side LLM rewriting of NotebookLM-native content into unsupported original copy.
## Compliance Boundary
- Positioning: research-report interpretation and annotation service.
- Content: Chinese interpretation of public or authorized institutional reports.
- Detail pages, agreements, and store metadata must state that content is not investment advice.
- Each item must show institution, source, publication time, and interpretation/source labels.
- Gray broker sources require special handling and human review before public release.
- Phase 1 does not open user content surfaces.
## Vision Decoupling
Vision source experience can be reused as reference material: source lists, source tiers, source-health lessons, NotebookLM experience, and prior pitfalls.
The app must not depend on local Vision runtime state in production. Any short-term Vision consumption must be read-only transition input, must not write back to Vision, and must not leak local file paths into production data.
@@ -0,0 +1,57 @@
# Roadmap and Open Issues
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## P0 Before Production Handoff
- Add environment examples and production-safe defaults for all deploy-time settings.
- Decide staging and production API domains.
- Implement `GET /audio/{audio_id}/stream` with short-lived signed playback URL.
- Implement auth start/verify flow and token handling.
- Implement `/me` personal-state APIs for favorites, history, saved listens, and playback progress.
- Implement `POST /outbound/events` with required `click_id` and `tracking_id`.
- Implement production cursor pagination.
- Implement cache invalidation on publish/hide/module/audio changes.
- Add smoke scripts for health, feed, detail, listen, audio stream, favorite, and outbound event.
## P1 Content and Admin
- Implement internal APIs for report import, raw artifacts, display artifacts, module patching, publish, hide, and related-source candidates.
- Implement production content importer from a manifest-based NotebookLM runner.
- Add validation for module JSON schemas.
- Add object storage integration for raw payloads, heavy module content, audio, images, and source references.
- Add publish blocking validation for P0 modules.
- Add gray-source review flags and operational reporting.
## P1 App/API Contract
- Align App with real auth state and return-to-action behavior.
- Add playable audio stream integration once backend stream endpoint exists.
- Replace local playback placeholders with API-backed progress.
- Add real outbound event write before external navigation.
- Decide whether heavy P1 modules stay as separate pages or merge into one deep-dive page.
## P2 Production Operations
- Add structured logs and request IDs.
- Add application metrics for feed/detail/listen/audio/outbound.
- Add backup and restore runbook for database and content objects.
- Add staging seed or reviewed staging content set.
- Add CI checks for lint, tests, migrations, and public response snapshots.
## Product and Compliance Open Issues
- Re-review gray-source audio policy before public release.
- Define AI-generated-content labeling requirements in App detail and store metadata.
- Define infographic watermark, QA, and factual-check process.
- Define source citation display rules after citation/page-label normalization.
- Confirm login channels and external approvals: phone SMS, WeChat, Apple.
- Confirm store listing wording and risk disclaimers.
## Gitea Handoff Blockers
- Use the single Gitea remote for the monorepo.
- Decide whether the initial push goes directly to `main` or to a review branch.
- Confirm the team has access to the product SSOT or accepts the code-repo snapshot as the development handoff.
+112
View File
@@ -0,0 +1,112 @@
# Backend Runbook
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Requirements
- Python 3.12 or compatible with the configured project dependencies.
- MySQL 8 for local/staging/prod-like runs.
- Redis 7 for cache-compatible local/staging/prod-like runs.
- A shell environment that can create a Python virtual environment.
SQLite is used by the automated tests through `RNB_DATABASE_URL`; production-like local runs should use MySQL.
## Environment Variables
Create `.env` in the repository root:
```bash
RNB_DATABASE_URL=mysql+asyncmy://<db-user>:<db-pass>@<db-host>:<db-port>/report_notebooklm
RNB_REDIS_URL=redis://<redis-host>:<redis-port>/0
RNB_REDIS_KEY_PREFIX=rnb:
```
Do not commit `.env`.
## Install
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
## Migrate and Seed
```bash
source .venv/bin/activate
alembic upgrade head
python scripts/import_seed_content.py
```
Seed import is destructive for seed tables. Use it only in local or disposable test data environments unless a production-safe importer is written.
## Run API
```bash
source .venv/bin/activate
uvicorn app.main:app --reload --host <bind-host> --port <port>
```
API prefix:
```text
/api/report-notebooklm/v1
```
## Smoke Checks
```bash
API_BASE_URL=http://<api-host>:<port>/api/report-notebooklm/v1
curl "$API_BASE_URL/health"
curl "$API_BASE_URL/feed/recommended"
curl "$API_BASE_URL/reports/rep_ssga_gold"
```
Expected:
- Health returns `{"status":"ok"}`.
- Feed returns non-empty `items`.
- Report detail returns modules and does not include `display_version`.
## Test
```bash
source .venv/bin/activate
pytest -q
```
## App Integration
Start this backend first, then run the App with:
```bash
flutter run -d chrome --dart-define=RNB_API_BASE=<api-base-url>
```
For Android emulator, use an API base URL reachable from that emulator:
```bash
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
Only use cleartext HTTP for local debug builds. Release builds must use HTTPS.
## Deployment Checks
Before staging or production:
- Use environment variables for all database, Redis, object storage, auth, and signing settings.
- Configure HTTPS at the gateway.
- Confirm migrations can run forward and downgrade in staging.
- Import reviewed content, not raw/unreviewed NotebookLM artifacts.
- Smoke `/health`, `/feed/recommended`, report detail, audio stream, favorites, and outbound event once those APIs exist.
- Confirm public responses do not expose local paths, raw payloads, notebook IDs, source IDs, conversation IDs, or secrets.
## Operational Notes
- Redis keys must use the `rnb:` prefix or a compatible namespace.
- Object storage keys should use `rnb/raw/`, `rnb/modules/`, `rnb/audio/`, and `rnb/images/` style prefixes.
- Long NotebookLM operations should live in a resumable runner, not inside HTTP request handlers.
@@ -0,0 +1,35 @@
# Source Index
This is a handoff snapshot, not the product SSOT.
Product SSOT: mall-docs report-notebooklm docs, snapshot date: 2026-06-03.
## Snapshot Sources
The handoff documents in this repository were distilled from these logical product-document sources:
| Logical source | Used for |
|---|---|
| `phase1-scope.md` | Product positioning, target users, tabs, Phase 1 scope, non-goals, compliance boundary, Vision decoupling. |
| `phase1-build-brief.md` | Data tables, endpoint list, display module model, artifact enum, seed mapping, open questions. |
| `phase1-development-plan.md` | Technology choices, architecture, Redis/object-storage strategy, phases, deployment assumptions, external dependencies. |
| `data-model-api-contract-v0.1.md` | API/data object intent and response boundaries. |
| `user-flows.md` | Guest vs logged-in behavior, shallow interaction expectations, no-download clarification. |
| `app-prd-v0.1.md` | App-side behavior and page-level expectations. |
| `vision-research-sources.md` | Source-reference context and Vision decoupling principle. |
## Drift Rule
Do not treat this repository snapshot as the product SSOT. When product requirements change:
1. Update the product SSOT first.
2. Update this code-repo snapshot only for information needed by engineers.
3. Bump the snapshot date or add a short changelog entry.
## What Was Not Copied
- Historical drafts.
- Full experiment reports.
- Local-only evidence paths.
- Private local notes.
- Raw NotebookLM notebook IDs, source IDs, conversation IDs, account identifiers, or payloads.
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import get_settings
from app.db import Base
from app.models import * # noqa: F401,F403
config = context.config
config.set_main_option("sqlalchemy.url", get_settings().database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=get_settings().database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
@@ -0,0 +1,23 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,26 @@
"""phase1 initial tables
Revision ID: 202606030100
Revises:
Create Date: 2026-06-03 01:00:00
"""
from alembic import op
from app.db import Base
from app.models import * # noqa: F401,F403
revision = "202606030100"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade() -> None:
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)
+32
View File
@@ -0,0 +1,32 @@
[project]
name = "report-notebooklm-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn",
"sqlalchemy",
"greenlet",
"alembic",
"pydantic",
"pydantic-settings",
"asyncmy",
"redis",
]
[project.optional-dependencies]
dev = [
"aiosqlite",
"httpx",
"pytest",
"pytest-asyncio",
]
[tool.setuptools.packages.find]
include = ["app*", "scripts*"]
exclude = ["migrations*", "tests*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
@@ -0,0 +1 @@
@@ -0,0 +1,649 @@
from __future__ import annotations
import asyncio
import csv
import datetime as dt
import hashlib
import re
import json
import sys
from typing import Any
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Base, SessionLocal, engine
from app.models import (
AudioAsset,
DisplayArtifact,
DisplayModule,
Favorite,
Institution,
OutboundEvent,
PlaybackProgress,
RawArtifact,
ReadingHistory,
RelatedNews,
Report,
SavedListen,
User,
)
def j(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def d(value: str) -> dt.datetime:
return dt.datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
def etag(value: Any) -> str:
return hashlib.sha256(j(value).encode("utf-8")).hexdigest()[:16]
REAL_SAMPLE_REPORT_ID = "rep_bis_notebooklm_sample"
REAL_SAMPLE_ROOT = (
Path.home()
/ "Projects/team-project/mall-docs/products/type3-orbit/report-notebooklm/docs.jimme.local/report-notebooklm/notebooklm-capability-bis-2026-06-02"
)
REAL_SAMPLE_ARTIFACTS = REAL_SAMPLE_ROOT / "artifacts"
def read_real_sample(name: str) -> str:
path = REAL_SAMPLE_ARTIFACTS / name
if not path.exists():
return ""
return path.read_text(encoding="utf-8-sig")
def clean_markdown_text(value: str) -> str:
text = re.sub(r"\[\d+(?:[-, ]+\d+)*\]", "", value)
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
text = text.replace("`", "")
return re.sub(r"\s+", " ", text).strip()
def markdown_sections(markdown: str, *, min_level: int = 2, limit: int = 8) -> list[dict[str, str]]:
sections: list[dict[str, str]] = []
current_heading = ""
current_lines: list[str] = []
heading_re = re.compile(r"^(#{%d,4})\s+(.+)$" % min_level)
for raw_line in markdown.splitlines():
line = raw_line.strip()
if line == "## Citations":
break
match = heading_re.match(line)
if match:
if current_heading and current_lines:
body = clean_markdown_text("\n".join(current_lines))
if body:
sections.append({"heading": current_heading, "body": body})
current_heading = clean_markdown_text(match.group(2))
current_lines = []
continue
if current_heading and line and not line.startswith("---") and not line.startswith("|"):
current_lines.append(line)
if current_heading and current_lines:
body = clean_markdown_text("\n".join(current_lines))
if body:
sections.append({"heading": current_heading, "body": body})
return sections[:limit]
def numbered_sections(markdown: str, *, limit: int = 8) -> list[dict[str, str]]:
sections: list[dict[str, str]] = []
pattern = re.compile(r"^(?:###\s*)?\d+\.\s+\**(.+?)\**$")
current_heading = ""
current_lines: list[str] = []
for raw_line in markdown.splitlines():
line = raw_line.strip()
if line == "## Citations":
break
match = pattern.match(line)
if match:
if current_heading and current_lines:
body = clean_markdown_text("\n".join(current_lines))
if body:
sections.append({"heading": current_heading, "body": body})
current_heading = clean_markdown_text(match.group(1))
current_lines = []
continue
if current_heading and line and not line.startswith("#") and not line.startswith("---"):
current_lines.append(line)
if current_heading and current_lines:
body = clean_markdown_text("\n".join(current_lines))
if body:
sections.append({"heading": current_heading, "body": body})
return sections[:limit]
def split_heading_body(section: dict[str, str]) -> tuple[str, str]:
body = section["body"]
parts = re.split(r"研报观点与证据:|证据:|影响:", body, maxsplit=1)
if len(parts) == 2:
return clean_markdown_text(parts[0]), clean_markdown_text(parts[1])
return "", body
def key_data_rows() -> list[dict[str, str]]:
csv_text = read_real_sample("data-table.csv")
rows: list[dict[str, str]] = []
if csv_text:
for row in csv.DictReader(csv_text.splitlines()):
rows.append(
{
"metric": row.get("数据点/指标名称", ""),
"value": row.get("定量数值或趋势", ""),
"unit": "",
"importance": row.get("风险/修订指示", ""),
"judgment": row.get("相关行业或资产类别", ""),
}
)
if rows:
return rows
return [
{"metric": "M7 市值占比", "value": "近 35%", "unit": "", "importance": "提示指数集中度风险", "judgment": "美国大型科技股"},
{"metric": "SRT 覆盖贷款", "value": "约 8000 亿欧元", "unit": "", "importance": "提示隐藏信贷风险规模", "judgment": "银行业 / 非银机构"},
]
def sample_artifact_types() -> list[str]:
return [
"describe-source",
"native_briefing_doc",
"native_blog_post",
"native_study_guide",
"data_table",
"query_dimensions",
"query_key_data",
"query_divergence",
"query_weaknesses",
"query_timeline",
"query_related_sources",
"audio_brief",
]
MODULE_TITLES = {
"basic_info": "报告概览",
"executive_overview": "报告摘要",
"audio": "听研报",
"core_insights": "报告要点",
"key_data": "报告中的关键数据",
"differentiated_view": "观点差异",
"weaknesses": "局限与疑问",
"timeline": "时间线",
"study_guide": "术语与问答",
"structure_graph": "结构梳理",
"related_sources": "延伸阅读",
"source_compliance": "报告来源",
}
MODULE_DISPLAY_ORDER = {module_type: index for index, module_type in enumerate(MODULE_TITLES)}
def real_sample_module_envelope(module_type: str, report_id: str, title: str, institution_name: str) -> dict[str, Any]:
briefing = read_real_sample("briefing-doc.md")
blog = read_real_sample("blog-post.md")
study = read_real_sample("study-guide.md")
dimensions = read_real_sample("query-dimensions.md")
key_data = read_real_sample("query-key-data.md")
divergence = read_real_sample("query-divergence.md")
weaknesses = read_real_sample("query-weaknesses.md")
timeline = read_real_sample("query-timeline.md")
related_sources = read_real_sample("query-related-sources.md")
briefing_sections = markdown_sections(briefing, min_level=2, limit=7)
blog_sections = markdown_sections(blog, min_level=3, limit=7)
dimension_sections = numbered_sections(dimensions, limit=5)
key_data_sections = numbered_sections(key_data, limit=12)
divergence_sections = numbered_sections(divergence, limit=5)
weakness_sections = numbered_sections(weaknesses, limit=5)
timeline_sections = numbered_sections(timeline, limit=10)
related_sections = numbered_sections(related_sources, limit=8)
study_faq = numbered_sections(study, limit=5)
key_rows = key_data_rows()
core_points = [
{"kind": "view", "text": "市场表面平静,但底层已经从美国大型科技股向欧洲、日本、新兴市场、价值股和小盘股重新轮动。"},
{"kind": "number", "text": "M7 在标普 500 指数中的市值占比接近 35%,单一板块波动正在显著影响指数风险。"},
{"kind": "risk", "text": "AI 基础设施融资从现金流叙事转向债务和表外融资,私人信贷、保险公司和银行授信之间的关联增强。"},
{"kind": "risk", "text": "白银在 2026 年 1 月先涨超 50%、后单日跌近 30%,暴露了杠杆 ETF 再平衡和保证金触发平仓的放大效应。"},
]
base = {
"basic_info": {
"content": {
"report_id": report_id,
"title_cn": title,
"summary_cn": "BIS 2026 年 3 月季度评论,回顾 2025 年 11 月 29 日至 2026 年 3 月 5 日的全球金融市场变化,覆盖市场轮动、AI 融资、私募信贷、贵金属和新兴市场政策反应。",
"topics": ["宏观金融", "金融稳定", "AI 融资", "非银风险"],
"interpretation_label": "研报解读",
}
},
"executive_overview": {
"preview": {
"preview_summary": "报告认为,本轮变化不是单一资产回调,而是高估值科技股、AI 基础设施融资、贵金属杠杆交易和非银信用链条共同推动的市场重新校准。它的核心价值在于把看似分散的市场波动,放回金融稳定和跨市场传导的框架里理解。",
"section_count": len(briefing_sections) + len(blog_sections),
"key_quote_snippet": "全球金融市场在表面的平静下经历了深刻的流向切换与重新校准。",
"highlights": ["资金从美国大型科技股转向欧洲、日本和新兴市场", "AI 基础设施融资开始暴露信用风险", "贵金属波动显示杠杆交易的放大效应"],
},
"full": {
"intro_cn": "这份报告把 2025 年底到 2026 年初的市场变化概括为一次跨资产重新校准:美国大型科技股降温,资金转向欧洲、日本和新兴市场;AI 融资从高成长叙事进入债务和表外风险阶段;贵金属和非银金融机构的波动说明杠杆与流动性仍是金融稳定的关键变量。",
"sections": briefing_sections + blog_sections[:4],
"source_artifacts": ["native_briefing_doc", "native_blog_post"],
},
},
"core_insights": {
"content": {"points": core_points},
"full": {
"points": core_points,
"dimensions": [{"dimension": item["heading"], "summary": item["body"]} for item in dimension_sections],
},
},
"key_data": {
"preview": {
"preview_headline": "8 个真实关键数据点",
"highlights": [f"{row['metric']}{row['value']}" for row in key_rows[:3]],
"row_count": len(key_rows),
},
"full": {
"rows": key_rows,
"source_artifacts": ["data_table", "query_key_data"],
"supporting_notes": [{"heading": item["heading"], "body": item["body"]} for item in key_data_sections[:6]],
},
},
"source_compliance": {
"content": {
"source_url": "https://www.bis.org/publ/qtrpdf/r_qt2603.htm",
"source_note": "原文为 BIS Quarterly Review, March 2026 的公开研报;本页仅提供中文解读,不提供解读内容下载。",
"copyright_cn": "原文版权归发布机构所有;本页为基于公开研报整理的中文阅读辅助。",
"disclaimer": "本内容仅供研报阅读参考,不构成投资建议。",
"ai_generated_label": "AI 辅助生成",
}
},
"differentiated_view": {
"preview": {
"preview_headline": "5 处与常见叙事的分歧",
"highlights": [item["heading"] for item in divergence_sections[:3]],
"divergence_count": len(divergence_sections),
},
"full": {
"divergences": [
{
"topic": item["heading"],
"consensus_view": split_heading_body(item)[0] or "常规叙事没有充分覆盖该维度。",
"report_position": split_heading_body(item)[1],
}
for item in divergence_sections
]
},
},
"weaknesses": {
"preview": {
"preview_headline": "5 个论证弱点与反方向证据",
"highlights": [item["heading"] for item in weakness_sections[:3]],
"item_count": len(weakness_sections),
"disclaimer_brief": "只做论证质量分析,不做投资建议。",
},
"full": {
"disclaimer_cn": "以下仅分析研报论证质量,不构成投资建议。",
"verification_notes": ["以上问题需要结合后续市场数据、原文脚注和反方向证据继续验证。"],
"items": [
{
"topic": item["heading"],
"weakness": item["body"],
"counter_evidence": "需要结合后续数据、原文脚注与反方向证据继续验证。",
}
for item in weakness_sections
],
},
},
"timeline": {
"preview": {
"preview_headline": "10 个关键事件节点",
"date_range": "1990s-2026",
"highlights": [item["heading"] for item in timeline_sections[:3]],
"event_count": len(timeline_sections),
},
"full": {
"events": [
{
"date": item["heading"],
"period_type": "report_timeline",
"event": item["heading"],
"impact": item["body"],
}
for item in timeline_sections
]
},
},
"study_guide": {
"preview": {
"preview_headline": "术语与问答",
"faq_count": len(study_faq),
"glossary_count": 8,
"sample_question": study_faq[0]["heading"] if study_faq else "为什么要读这份 BIS 季报?",
"highlights": ["核心概念摘要", "简答练习题", "重要术语表"],
},
"full": {
"intro_cn": "这一部分整理了阅读本篇研报时容易遇到的概念、问题和术语。",
"faq_items": [{"question": item["heading"], "answer": item["body"]} for item in study_faq],
"glossary": [
{"term": "M7", "definition": "主导美国股市的七大科技巨头。"},
{"term": "SRT", "definition": "合成风险转移,银行通过衍生品或担保转移部分信用风险。"},
{"term": "BISTRO", "definition": "BIS Time-series Regression Oracle,宏观时间序列预测工具。"},
{"term": "NBFI", "definition": "非银行金融机构。"},
{"term": "Shadow Borrowing", "definition": "经济实质类似债务、但主要存在于资产负债表外的融资安排。"},
{"term": "BDCs", "definition": "业务发展公司,是私募信贷市场的公开交易窗口之一。"},
{"term": "Carry Trade", "definition": "借入低息货币、投资高息资产的套利交易。"},
{"term": "Margin-triggered Liquidations", "definition": "保证金要求上升触发的被迫平仓。"},
],
},
},
"structure_graph": {
"preview": {
"preview_headline": "结构梳理",
"root": "BIS 季报:分析框架",
"top_nodes": [item["heading"] for item in dimension_sections[:5]],
"fallback_derived": True,
},
"full": {
"root": "BIS 季报:分析框架",
"nodes": [
{
"label": item["heading"],
"children": [phrase.strip("") for phrase in re.split(r"[。;;]", item["body"])[:3] if phrase.strip()],
}
for item in dimension_sections
],
"fallback_derived": True,
"source_artifacts": ["query_dimensions"],
},
},
"related_sources": {
"content": {
"items": [
{"title": item["heading"], "source_name": "延伸资料", "summary_cn": item["body"]}
for item in related_sections[:3]
],
"review_note": "延伸来源仅作为候选队列,正式展示前需要人工审核。",
},
"full": {
"items": [
{"title": item["heading"], "source_name": "延伸资料", "summary_cn": item["body"]}
for item in related_sections
],
"review_note": "延伸来源仅作为候选队列,正式展示前需要人工审核。",
},
},
"audio": {
"content": {
"audio_id": "aud_bis_notebooklm_sample",
"title_cn": "BIS 季度评论",
"duration_sec": 75,
"chapters": [],
}
},
}
return base[module_type]
INSTITUTIONS = [
("inst_wgc", "世界黄金协会", "World Gold Council", "industry_org", "tier_1", "https://www.gold.org/", ["贵金属", "央行"]),
("inst_imf", "国际货币基金组织", "International Monetary Fund", "international_org", "tier_1", "https://www.imf.org/", ["宏观金融", "外汇"]),
("inst_world_bank", "世界银行", "World Bank", "international_org", "tier_1", "https://www.worldbank.org/", ["大宗商品", "发展经济"]),
("inst_iea", "国际能源署", "International Energy Agency", "international_org", "tier_1", "https://www.iea.org/", ["能源", "原油"]),
("inst_eia", "美国能源信息署", "U.S. Energy Information Administration", "official", "tier_1", "https://www.eia.gov/", ["能源", "原油"]),
("inst_usgs", "美国地质调查局", "U.S. Geological Survey", "official", "tier_1", "https://www.usgs.gov/", ["矿产", "贵金属"]),
("inst_ecb", "欧洲央行", "European Central Bank", "official", "tier_1", "https://www.ecb.europa.eu/", ["货币政策", "欧元区"]),
("inst_bis", "国际清算银行", "Bank for International Settlements", "international_org", "tier_1", "https://www.bis.org/", ["宏观金融", "金融稳定"]),
("inst_fed", "美联储", "Federal Reserve", "official", "tier_1", "https://www.federalreserve.gov/", ["货币政策", "美元"]),
("inst_opec", "欧佩克", "OPEC", "international_org", "tier_1", "https://www.opec.org/", ["能源", "原油"]),
("inst_ssga", "道富环球投资管理", "State Street Global Advisors", "asset_manager", "tier_2", "https://www.ssga.com/", ["贵金属", "跨资产"]),
("inst_wisdomtree", "WisdomTree", "WisdomTree", "asset_manager", "tier_2", "https://www.wisdomtree.com/", ["大宗商品", "资产配置"]),
("inst_ing", "ING 银行研究", "ING Think", "bank_research", "tier_2", "https://think.ing.com/", ["贵金属", "外汇"]),
("inst_silver_institute", "白银协会", "The Silver Institute", "industry_org", "tier_2", "https://silverinstitute.org/", ["白银", "矿产"]),
("inst_goldman", "高盛研究", "Goldman Sachs Research", "bank_research", "tier_3", "https://www.goldmansachs.com/", ["大宗商品", "宏观"]),
("inst_jpm", "摩根大通研究", "J.P. Morgan Research", "bank_research", "tier_3", "https://www.jpmorgan.com/", ["大宗商品", "宏观"]),
("inst_invesco", "景顺", "Invesco", "asset_manager", "tier_3", "https://www.invesco.com/", ["ETF", "资产配置"]),
("inst_pas", "泛美白银", "Pan American Silver", "partner", "tier_3", "https://www.panamericansilver.com/", ["白银", "矿业"]),
]
BASE_REPORTS = [
(REAL_SAMPLE_REPORT_ID, "BIS 季度评论:全球金融市场重新校准", "inst_bis", "official_public", True, ["宏观金融", "金融稳定", "AI 融资", "非银风险"], "2026-06-02T00:00:00Z"),
("rep_ssga_gold", "黄金月报:金价新高之后,谁在继续买?", "inst_ssga", "authorized_partner", True, ["贵金属", "跨资产"], "2026-05-22T00:00:00Z"),
("rep_wb_pinksheet", "世界银行大宗商品价格表:金属分化继续", "inst_world_bank", "official_public", True, ["大宗商品", "金属"], "2026-05-20T00:00:00Z"),
("rep_iea_omr", "IEA 原油市场月报:库存与需求再平衡", "inst_iea", "official_public", True, ["能源", "原油"], "2026-05-18T00:00:00Z"),
("rep_ing_gold", "ING 黄金观点:实际利率回摆的压力测试", "inst_ing", "authorized_partner", False, ["贵金属", "外汇"], "2026-05-16T00:00:00Z"),
("rep_wisdomtree_outlook", "WisdomTree 商品展望:配置窗口与回撤风险", "inst_wisdomtree", "authorized_partner", False, ["大宗商品", "资产配置"], "2026-05-14T00:00:00Z"),
("rep_usgs_minerals", "USGS 矿产摘要:关键金属供给约束", "inst_usgs", "official_public", True, ["矿产", "贵金属"], "2026-05-12T00:00:00Z"),
("rep_pas_silver", "白银矿业更新:供给扰动与成本曲线", "inst_pas", "broker_public_gray", False, ["白银", "矿业"], "2026-05-10T00:00:00Z"),
("rep_eia_steo", "EIA 短期能源展望:油气价格情景", "inst_eia", "official_public", True, ["能源", "原油"], "2026-05-08T00:00:00Z"),
]
LIGHT_REPORTS = [
("rep_imf_weo", "IMF 世界经济展望:增长分化与政策空间", "inst_imf", "official_public", True, ["宏观金融"], "2026-05-06T00:00:00Z"),
("rep_bis_quarterly", "BIS 季报:市场重新校准", "inst_bis", "official_public", True, ["宏观金融", "金融稳定"], "2026-05-04T00:00:00Z"),
("rep_fed_fsr", "美联储金融稳定报告:杠杆与流动性", "inst_fed", "official_public", True, ["金融稳定"], "2026-05-02T00:00:00Z"),
("rep_ecb_bulletin", "欧洲央行经济公报:通胀路径更新", "inst_ecb", "official_public", True, ["货币政策"], "2026-04-30T00:00:00Z"),
("rep_opec_momr", "OPEC 月报:供需缺口与配额纪律", "inst_opec", "official_public", True, ["能源", "原油"], "2026-04-28T00:00:00Z"),
("rep_wgc_trends", "世界黄金协会:黄金需求趋势", "inst_wgc", "official_public", True, ["贵金属", "央行"], "2026-04-26T00:00:00Z"),
("rep_silver_survey", "白银协会:白银供需调查", "inst_silver_institute", "official_public", True, ["白银"], "2026-04-24T00:00:00Z"),
("rep_gs_commodity", "高盛商品观点:再通胀交易复盘", "inst_goldman", "broker_public_gray", False, ["大宗商品"], "2026-04-22T00:00:00Z"),
("rep_jpm_flows", "摩根大通资金流:商品 ETF 与风险偏好", "inst_jpm", "authorized_partner", False, ["跨资产"], "2026-04-20T00:00:00Z"),
("rep_invesco_etf", "景顺 ETF 观察:黄金与能源配置", "inst_invesco", "authorized_partner", False, ["ETF", "贵金属"], "2026-04-18T00:00:00Z"),
("rep_world_bank_macro", "世界银行宏观更新:贸易与大宗商品", "inst_world_bank", "official_public", True, ["宏观金融", "大宗商品"], "2026-04-16T00:00:00Z"),
("rep_iea_gas", "IEA 天然气市场报告:需求弹性", "inst_iea", "official_public", True, ["能源"], "2026-04-14T00:00:00Z"),
("rep_eia_inventory", "EIA 库存周报解读:裂解价差与需求", "inst_eia", "official_public", False, ["能源"], "2026-04-12T00:00:00Z"),
("rep_usgs_copper", "USGS 铜矿供给:项目延迟与品位下降", "inst_usgs", "official_public", False, ["矿产"], "2026-04-10T00:00:00Z"),
("rep_ing_fx", "ING 外汇周报:美元路径与黄金敏感性", "inst_ing", "authorized_partner", False, ["外汇", "贵金属"], "2026-04-08T00:00:00Z"),
("rep_wisdomtree_gold", "WisdomTree 黄金配置:避险与实际利率", "inst_wisdomtree", "authorized_partner", False, ["贵金属"], "2026-04-06T00:00:00Z"),
("rep_ecb_stability", "欧洲央行稳定评估:非银金融风险", "inst_ecb", "official_public", False, ["金融稳定"], "2026-04-04T00:00:00Z"),
("rep_bis_ai_credit", "BIS 专题:AI 融资与信用风险", "inst_bis", "official_public", False, ["金融稳定", "AI"], "2026-04-02T00:00:00Z"),
]
def module_envelope(module_type: str, report_id: str, title: str, institution_name: str, *, fallback: bool = False) -> dict[str, Any]:
base = {
"basic_info": {"content": {"report_id": report_id, "title_cn": title, "summary_cn": f"{title} 的基础信息,包含发布机构、发布时间、主题标签和来源层级。", "topics": ["贵金属"], "interpretation_label": "研报解读"}},
"executive_overview": {
"preview": {"preview_summary": f"{title} 的结构化摘要,聚焦核心结论、数据线索与风险边界。", "section_count": 3, "key_quote_snippet": "公开研报显示关键变量正在重新定价。"},
"full": {"intro_cn": f"{title} 的执行摘要。", "sections": [{"heading": "核心结论", "body": "报告把需求、价格和风险拆成可读结构。"}, {"heading": "数据线索", "body": "关键指标用于判断趋势是否可持续。"}, {"heading": "风险边界", "body": "外部冲击和估值回摆仍可能改变短期路径。"}], "source_artifacts": ["native_briefing_doc", "native_blog_post"]},
},
"core_insights": {"content": {"points": [{"kind": "view", "text": "核心变量从情绪驱动转向结构驱动。"}, {"kind": "number", "text": "多项关键指标出现同步变化。"}, {"kind": "risk", "text": "若宏观假设反转,短期波动会放大。"}]}, "full": {"dimensions": [{"dimension": "需求结构", "summary": "机构、ETF 与产业需求变化共同影响价格。"}, {"dimension": "风险路径", "summary": "利率、美元和地缘冲击是主要风险因子。"}]}},
"key_data": {"preview": {"preview_headline": "10 个关键数据点", "highlights": ["央行购金保持韧性", "ETF 资金重新流入", "库存周期出现分化"], "row_count": 10}, "full": {"rows": [{"metric": "样本指标", "value": "10", "unit": "", "importance": "用于验证关键数据模块渲染", "judgment": "方向性信号清晰"}], "source_artifacts": ["data_table", "query_key_data"]}},
"source_compliance": {"content": {"source_url": None if report_id == "rep_pas_silver" else "https://example.org/public-report", "source_note": "灰度来源仅展示来源说明,不提供原文链接。" if report_id == "rep_pas_silver" else "原文来源于机构公开研究页。", "copyright_cn": "内容基于机构公开研报的中文结构化解读。", "disclaimer": "本内容不构成投资建议。", "ai_generated_label": "AI 辅助生成"}},
"differentiated_view": {"preview": {"preview_headline": "3 处与共识的关键分歧", "highlights": ["结构性买盘强于短期情绪", "库存周期解释部分价格韧性"], "divergence_count": 3}, "full": {"divergences": [{"topic": "买盘结构", "consensus_view": "价格主要由短期情绪驱动。", "report_position": "报告强调更稳定的结构性买盘。"}]}},
"weaknesses": {"preview": {"preview_headline": "3 处质疑点与开放问题", "highlights": ["样本窗口偏短", "反方向证据仍需跟踪"], "item_count": 3, "disclaimer_brief": "AI 辅助论证质量分析"}, "full": {"disclaimer_cn": "仅供学习参考,不构成投资建议。", "verification_notes": ["这些开放问题需要结合后续数据、原文脚注和反方向证据继续验证。"], "items": [{"topic": "样本窗口", "weakness": "短周期数据可能放大结论。", "counter_evidence": "后续数据可能修正方向。"}]}},
"timeline": {"preview": {"preview_headline": "5 个关键事件节点", "date_range": "2025-2026", "highlights": ["2026:价格重新定价", "2025:资金结构切换"], "event_count": 5}, "full": {"events": [{"date": "2026-05", "period_type": "review_period", "event": "报告发布", "impact": "为市场判断提供公开依据。"}]}},
"study_guide": {"preview": {"preview_headline": "学习指南", "faq_count": 3, "glossary_count": 5, "sample_question": "这份报告适合谁读?"}, "full": {"intro_cn": "学习指南帮助读者理解术语和关键问题。", "faq_items": [{"question": "这份报告适合谁读?", "answer": "适合关注宏观、商品和资产配置的读者。"}], "glossary": [{"term": "source_tier", "definition": "来源可信层级。"}]}},
"structure_graph": {"preview": {"preview_headline": "研报结构图", "root": f"{title}:分析框架", "top_nodes": ["需求", "价格", "风险"], "fallback_derived": fallback}, "full": {"root": f"{title}:分析框架", "nodes": [{"label": "需求", "children": ["机构", "产业", "投资"]}, {"label": "价格", "children": ["利率", "美元", "库存"]}], "fallback_derived": fallback, "source_artifacts": ["query_dimensions"] if fallback else ["mind_map"]}},
"audio": {"content": {"audio_id": f"aud_{report_id.removeprefix('rep_')}", "title_cn": f"{title} 音频摘要", "duration_sec": 180, "chapters": []}},
}
return base[module_type]
def rich_module_types(report_id: str) -> list[str]:
by_report = {
REAL_SAMPLE_REPORT_ID: [
"basic_info",
"executive_overview",
"core_insights",
"key_data",
"source_compliance",
"institution",
"differentiated_view",
"weaknesses",
"timeline",
"study_guide",
"structure_graph",
"related_sources",
"audio",
],
"rep_ssga_gold": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "differentiated_view", "weaknesses", "timeline", "study_guide", "structure_graph", "audio"],
"rep_wb_pinksheet": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "timeline", "study_guide", "audio"],
"rep_iea_omr": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "study_guide", "structure_graph", "audio"],
"rep_ing_gold": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution"],
"rep_wisdomtree_outlook": ["basic_info", "executive_overview", "core_insights", "source_compliance", "institution", "timeline"],
"rep_usgs_minerals": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "timeline", "structure_graph", "audio"],
"rep_pas_silver": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution"],
"rep_eia_steo": ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution", "study_guide", "audio"],
}
return by_report.get(report_id, ["basic_info", "executive_overview", "core_insights", "key_data", "source_compliance", "institution"])
async def reset(session: AsyncSession) -> None:
for model in [
OutboundEvent,
PlaybackProgress,
SavedListen,
ReadingHistory,
Favorite,
User,
RelatedNews,
AudioAsset,
DisplayModule,
DisplayArtifact,
RawArtifact,
Report,
Institution,
]:
await session.execute(delete(model))
await session.commit()
async def import_seed(session: AsyncSession) -> None:
await reset(session)
inst_lookup: dict[str, str] = {}
for inst_id, name_cn, name_en, inst_type, tier, url, topics in INSTITUTIONS:
inst_lookup[inst_id] = name_cn
session.add(Institution(institution_id=inst_id, name_cn=name_cn, name_en=name_en, institution_type=inst_type, source_tier=tier, website_url=url, covered_topics=j(topics), intro_cn=f"{name_cn} 的公开研究和数据用于 Phase 1 seed 展示。", credibility_note=f"{name_cn}{tier} 来源。", status="active"))
await session.flush()
all_reports = BASE_REPORTS + LIGHT_REPORTS
audio_report_ids = {report_id for report_id, *_rest, has_audio, _topics, _date in all_reports if has_audio}
for idx, (report_id, title, inst_id, source_tier, has_audio, topics, released) in enumerate(all_reports, start=1):
display_status = "draft" if report_id == "rep_wisdomtree_outlook" else "published"
source_url = None if source_tier == "broker_public_gray" else "https://example.org/public-report"
source_note = "灰度公开来源,仅保留来源说明,不做默认音频化。" if source_tier == "broker_public_gray" else "原文来源于机构公开研究页。"
if report_id == REAL_SAMPLE_REPORT_ID:
source_url = "https://www.bis.org/publ/qtrpdf/r_qt2603.htm"
source_note = "原文为 BIS Quarterly Review, March 2026 的公开研报。"
session.add(
Report(
report_id=report_id,
report_type="single",
title_cn=title,
subtitle_cn="",
original_title="BIS Quarterly Review, March 2026" if report_id == REAL_SAMPLE_REPORT_ID else f"{title} original",
one_liner="2025 年底至 2026 年初,全球金融市场在表面平静下出现资金流向切换,AI 融资、贵金属杠杆和非银风险成为主要线索。" if report_id == REAL_SAMPLE_REPORT_ID else f"{title} 的一分钟结构化摘要。",
institution_id=inst_id,
source_tier=source_tier,
source_url=source_url,
source_note=source_note,
published_at=d(released),
interpreted_at=d(released),
released_at=d(released),
topics=j(topics),
language="en",
has_audio=has_audio,
display_status=display_status,
display_version=1,
cache_version=f"{report_id}:v1",
risk_disclaimer="本内容为公开研报的结构化解读,不构成投资建议。",
interpretation_label="研报解读",
)
)
await session.flush()
da_id = f"da_{report_id.removeprefix('rep_')}_v1"
session.add(DisplayArtifact(display_artifact_id=da_id, report_id=report_id, display_version=1, title_cn=title, summary_cn=f"{title} seed display artifact", source_label=inst_lookup[inst_id], interpretation_label="研报解读", ai_generated_label="AI 辅助生成", synthesis_type="mixed" if has_audio else "text", source_disclosure_text=source_note, review_status="published", published_at=d(released)))
await session.flush()
artifact_types = sample_artifact_types() if report_id == REAL_SAMPLE_REPORT_ID else ["native_briefing_doc", "native_blog_post", "native_study_guide", "data_table", "query_dimensions", "query_key_data"]
for artifact_type in artifact_types:
session.add(RawArtifact(raw_artifact_id=f"raw_{report_id.removeprefix('rep_')}_{artifact_type}", report_id=report_id, artifact_type=artifact_type, payload_format="markdown" if artifact_type != "data_table" else "csv", status="ok", is_publish_blocking=artifact_type in {"native_briefing_doc", "native_blog_post", "data_table", "query_dimensions", "query_key_data"}, retention_status="retained", ingested_at=d(released)))
if report_id == "rep_iea_omr":
session.add(RawArtifact(raw_artifact_id="raw_iea_omr_mind_map", report_id=report_id, artifact_type="mind_map", payload_format="json", status="failed", error="Download failed for mind_map", is_publish_blocking=False, retention_status="retained", ingested_at=d(released)))
module_types = [
value
for value in sorted(
rich_module_types(report_id),
key=lambda value: MODULE_DISPLAY_ORDER.get(value, len(MODULE_DISPLAY_ORDER)),
)
if value != "institution"
]
for order, module_type in enumerate(module_types):
if report_id == REAL_SAMPLE_REPORT_ID:
payload = real_sample_module_envelope(module_type, report_id, title, inst_lookup[inst_id])
else:
payload = module_envelope(module_type, report_id, title, inst_lookup[inst_id], fallback=(report_id == "rep_iea_omr" and module_type == "structure_graph"))
module_id = f"mod_{report_id.removeprefix('rep_')}_{module_type}"
content_ref = f"rnb/modules/{module_id}.json" if "full" in payload else None
session.add(
DisplayModule(
module_id=module_id,
report_id=report_id,
display_artifact_id=da_id,
type=module_type,
title_cn=MODULE_TITLES.get(module_type, module_type),
content_format="json",
content=j(payload),
content_ref=content_ref,
content_etag=etag(payload),
source_raw_artifact_ids=j([]),
status="published" if display_status == "published" else "review",
sort_order=order,
version=1,
)
)
if has_audio and report_id in audio_report_ids:
audio_id = f"aud_{report_id.removeprefix('rep_')}"
session.add(AudioAsset(audio_id=audio_id, report_id=report_id, title_cn=f"{title} 音频摘要", duration_sec=180 + idx, oss_key=f"rnb/audio/{audio_id}.m4a", chapters=j([]), status="published" if display_status == "published" else "review", published_at=d(released)))
if idx <= 15:
session.add(RelatedNews(related_news_id=f"news_{idx:03d}", report_id=report_id, title=f"{title} 延伸阅读", source_name="公开财经资讯", source_url="https://example.org/news", published_at=d(released), language="zh", summary_cn="整理自公开财经资讯的延伸阅读。", match_method="manual_curated", match_keywords=j(topics), match_confidence="medium", status="published"))
await session.flush()
for inst_id in inst_lookup:
count = await session.scalar(select(Report).where(Report.institution_id == inst_id).count()) if False else None
reports = (await session.execute(select(Report).where(Report.institution_id == inst_id, Report.display_status == "published").order_by(Report.released_at.desc()))).scalars().all()
inst = (await session.execute(select(Institution).where(Institution.institution_id == inst_id))).scalar_one()
inst.report_count = len(reports)
if reports:
inst.latest_report_id = reports[0].report_id
inst.latest_report_at = reports[0].released_at
users = [
User(user_id="user_alpha", phone_hash="hash_alpha", display_name="Alpha", status="active"),
User(user_id="user_history", phone_hash="hash_history", display_name="History", status="active"),
User(user_id="user_guest_placeholder", display_name="Guest Placeholder", status="disabled"),
]
session.add_all(users)
await session.flush()
for idx, report_id in enumerate(["rep_ssga_gold", "rep_wb_pinksheet", "rep_iea_omr", "rep_usgs_minerals", "rep_eia_steo"], start=1):
session.add(Favorite(favorite_id=f"fav_{idx:03d}", user_id="user_alpha", report_id=report_id, status="active"))
for idx, report_id in enumerate(["rep_ssga_gold", "rep_wb_pinksheet", "rep_iea_omr"], start=1):
audio_id = f"aud_{report_id.removeprefix('rep_')}"
session.add(PlaybackProgress(progress_id=f"prog_{idx:03d}", user_id="user_alpha", audio_id=audio_id, report_id=report_id, position_sec=idx * 30, duration_sec=180 + idx, completed=False))
await session.commit()
async def main() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session:
await import_seed(session)
print("seed import complete")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,117 @@
from __future__ import annotations
import os
os.environ["RNB_DATABASE_URL"] = "sqlite+aiosqlite:///./test_seed.db"
os.environ["RNB_REDIS_URL"] = "redis://test-redis.invalid/0"
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db import Base, SessionLocal, engine
from app.main import app
from app.models import AudioAsset, DisplayModule, Institution, Report
from scripts.import_seed_content import import_seed
PREFIX = "/api/report-notebooklm/v1"
@pytest.fixture(autouse=True)
async def seeded_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session:
await import_seed(session)
yield
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def test_seed_counts_match_phase1_shape():
async with SessionLocal() as session:
assert len((await session.execute(select(Institution))).scalars().all()) == 18
assert len((await session.execute(select(Report))).scalars().all()) == 27
assert len((await session.execute(select(AudioAsset))).scalars().all()) == 15
assert len((await session.execute(select(DisplayModule))).scalars().all()) >= 120
async def test_health_and_recommended_feed(client: AsyncClient):
health = await client.get(f"{PREFIX}/health")
assert health.status_code == 200
assert health.json() == {"status": "ok"}
feed = await client.get(f"{PREFIX}/feed/recommended")
assert feed.status_code == 200
body = feed.json()
assert body["items"]
assert body["items"][0]["report_id"] == "rep_bis_notebooklm_sample"
assert "display_version" not in body["items"][0]
assert body["items"][0]["cache_version"].startswith("rep_")
async def test_report_detail_hides_internal_fields_and_review_modules(client: AsyncClient):
response = await client.get(f"{PREFIX}/reports/rep_ssga_gold")
assert response.status_code == 200
body = response.json()
assert body["report_id"] == "rep_ssga_gold"
assert "display_version" not in body
module_types = [module["type"] for module in body["modules"]]
assert "study_guide" in module_types
assert "institution" not in module_types
assert "faq" not in module_types
assert "infographic" not in module_types
assert all(module["has_detail_page"] for module in body["modules"])
assert module_types[-1] == "source_compliance"
key_data = next(module for module in body["modules"] if module["type"] == "key_data")
assert key_data["render_mode"] == "card_plus_page"
assert key_data["content"] is None
assert key_data["preview"]["row_count"] == 10
assert key_data["content_ref"].startswith("rnb/modules/")
async def test_module_endpoint_returns_full_content(client: AsyncClient):
detail = (await client.get(f"{PREFIX}/reports/rep_ssga_gold")).json()
key_data = next(module for module in detail["modules"] if module["type"] == "key_data")
response = await client.get(f"{PREFIX}/reports/rep_ssga_gold/modules/{key_data['module_id']}")
assert response.status_code == 200
body = response.json()
assert body["module_id"] == key_data["module_id"]
assert "rows" in body["content"]
assert body["cache_version"] == "rep_ssga_gold:v1"
async def test_boundary_reports(client: AsyncClient):
listen = (await client.get(f"{PREFIX}/listen")).json()
listen_report_ids = {item["report_id"] for item in listen["items"]}
assert "rep_ing_gold" not in listen_report_ids
assert "rep_pas_silver" not in listen_report_ids
hidden = await client.get(f"{PREFIX}/reports/rep_wisdomtree_outlook")
assert hidden.status_code == 404
gray = (await client.get(f"{PREFIX}/reports/rep_pas_silver")).json()
compliance = next(module for module in gray["modules"] if module["type"] == "source_compliance")
assert compliance["content"]["source_url"] is None
assert "灰度" in compliance["content"]["source_note"]
async def test_institutions_and_listen(client: AsyncClient):
institutions = await client.get(f"{PREFIX}/institutions")
assert institutions.status_code == 200
assert len(institutions.json()["items"]) == 18
inst = await client.get(f"{PREFIX}/institutions/inst_ssga")
assert inst.status_code == 200
assert inst.json()["latest_report"]["report_id"] == "rep_ssga_gold"
listen = await client.get(f"{PREFIX}/listen")
assert listen.status_code == 200
assert listen.json()["items"][0]["audio_id"].startswith("aud_")
+47
View File
@@ -0,0 +1,47 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
*.apk
build/verification/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+77
View File
@@ -0,0 +1,77 @@
# report-notebooklm-app
report-notebooklm 第一阶段应用外壳的 Flutter 客户端。
后端 API 在同一个 monorepo 的 `../report-notebooklm-api/` 里。API、数据、内容流水线的细节都记在那边;这个目录专注于应用交接、UI 状态、构建命令和对接说明。
## 先读这些
- [docs/HANDOFF.md](docs/HANDOFF.md):当前应用状态、已实现的页面、占位项,以及下一步工作。
- [docs/PROJECT_BRIEF.md](docs/PROJECT_BRIEF.md):产品和第一阶段范围速览。
- [docs/APP_RUNBOOK.md](docs/APP_RUNBOOK.md)Flutter 版本、本地运行、Web 构建、Android 调试构建和验证。
- [docs/API_CONTRACT_NOTES.md](docs/API_CONTRACT_NOTES.md):应用所消费的接口和字段。
- [docs/PROJECT_MAP.md](docs/PROJECT_MAP.md):源码目录地图。
## 产品边界
这个仓库装的是应用代码和一份工程交接快照,不是产品的唯一真源。
产品 SSOTmall-docs 里的 report-notebooklm 文档。快照日期:2026-06-03。
技术标识符用 `report-notebooklm``rnb`,面向用户的产品名是 `研听`
## 环境要求
- Flutter 3.44.1 / Dart 3.12.1,或兼容的更新版本。
- 一个正在运行、提供 `/api/report-notebooklm/v1` 的后端。
- 做 Android 构建还需要:Android SDK、已接受的许可协议,以及一台模拟器或真机。
## API 基础地址
应用刻意不内置任何线上 API 默认值。请显式传入后端基础地址:
```bash
flutter run -d chrome --dart-define=RNB_API_BASE=<api-base-url>
```
Android 模拟器:
```bash
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
同一局域网内的 Android 真机:
```bash
flutter run -d <device-id> --dart-define=RNB_API_BASE=http://<host-lan-ip>:<port>/api/report-notebooklm/v1
```
明文 HTTP 只能用于调试构建。发布构建必须使用 HTTPS。
## 验证
```bash
flutter analyze
flutter test
flutter build web --dart-define=RNB_API_BASE=<api-base-url>
flutter build apk --debug --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## 当前应用范围
已实现:
- 五个底部标签页:推荐、研报、机构、听单、我的。
- 基于 API 的信息流、研报列表、机构列表、听单、机构详情和研报详情。
- 用于内联模块和「卡片 + 页面」模块的模块渲染器注册表。
- 产品显示名 `研听`
- 登录、收藏、外链跳转确认、播放进度的本地 UI 占位。
尚未实现:
- 真实鉴权。
- 真实的收藏 / 历史 / 收听记录同步。
- 真正可播放的音频流。
- 真实的外链事件写入。
- 生产 API 域名。
- 发布签名、最终图标和最终应用商店元信息。
@@ -1,6 +1,5 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
@@ -15,10 +14,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.report_notebooklm_app"
@@ -39,6 +34,12 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

Some files were not shown because too many files have changed in this diff Show More