Compare commits

..

15 Commits

Author SHA1 Message Date
jingyun 1f28a64e4f fix:登录和toast 2026-06-07 12:03:23 +08:00
jingyun 6c943f8394 fix:登录退出页 2026-06-07 11:38:08 +08:00
jingyun 6a7fa5a067 fix:已登录显示 2026-06-07 11:16:38 +08:00
jingyun 544468f207 fix:搜索和我的页面 2026-06-07 11:16:19 +08:00
jingyun ac794ae58a fix:对比原型增加功能交互 2026-06-07 10:58:05 +08:00
jingyun af865b13fb fix;设置和深浅色 2026-06-05 17:54:46 +08:00
jingyun 33d04a5545 fix:导航栏交互和UI 2026-06-05 16:05:32 +08:00
jingyun c5288f397d fix:按照shadcn_ui对着demo_shadcn对齐 2026-06-05 15:04:39 +08:00
jingyun 9727b906c6 fix:按html的假数据demo 2026-06-05 11:12:55 +08:00
jingyun b4272b5ec9 fix:安卓报错版本对齐 2026-06-03 16:38:32 +08:00
jingyun e2554edfab fix:优化使用常用技术框架 2026-06-03 16:29:53 +08:00
jimme e93356e849 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 a76ea8dd07 fix:编译iOS和web目录 2026-06-03 10:17:39 +08:00
jingyun 4a632ba60f fix:flutter的sdk过高,适应当前开发版本 2026-06-03 10:17:17 +08:00
jimme 634ae98dec chore: prepare yanting monorepo handoff 2026-06-03 10:39:03 +09:00
196 changed files with 7932 additions and 5897 deletions
+45 -47
View File
@@ -1,49 +1,47 @@
# 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
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
**/.DS_Store
.idea/
*.iml
.vscode/
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# Build artifacts
build/
dist/
coverage/
# 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
-126
View File
@@ -1,126 +0,0 @@
# 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:`.
+38 -101
View File
@@ -1,107 +1,36 @@
# 研听 / report-notebooklm
# report-notebooklm-app
`研听` 是一个第一阶段(Phase 1)的应用和后端,用来把全球机构研报转化成结构化的中文阅读与收听体验
report-notebooklm 第一阶段应用外壳的 Flutter 客户端
这个仓库被整理成单个 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、付费解锁、会员、广告、交易信号、投资建议、研报解读下载。
后端 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):源码目录地图。
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
这个仓库装的是应用代码和一份工程交接快照,不是产品的唯一真源。
1. `AGENTS.md`
2. `docs/DECISIONS.md`
3. 对应子系统的 README 和运维手册。
产品 SSOTmall-docs 里的 report-notebooklm 文档。快照日期:2026-06-03。
## 当前实现状态
技术标识符用 `report-notebooklm``rnb`,面向用户的产品名是 `研听`
后端已实现:
## 环境要求
- 挂在 `/api/report-notebooklm/v1` 下的 FastAPI 应用
- 第一阶段数据表的 SQLAlchemy 模型层
- Alembic 初始迁移
- 种子数据导入脚本。
- 健康检查、信息流、研报、研报模块、机构、听单的对外只读接口。
- 针对种子数据和对外 API 行为的测试。
- Flutter 3.44.1 / Dart 3.12.1,或兼容的更新版本
- 一个正在运行、提供 `/api/report-notebooklm/v1` 的后端
- 做 Android 构建还需要:Android SDK、已接受的许可协议,以及一台模拟器或真机
应用已实现:
## API 基础地址
- 五个底部标签页:推荐、研报、机构、听单、我的。
- 基于 `RNB_API_BASE` 的列表 / 详情视图。
- 研报详情的模块渲染器注册表。
- 登录、收藏、外链跳转确认、播放进度的本地占位实现。
- Android 和 Web 构建脚手架。
尚未达到生产可用:
- 鉴权和个人状态。
- 真实的音频流签名。
- 外链事件写入。
- 内部内容管理 API。
- 生产环境对象存储和缓存失效。
- 生产 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>
```
@@ -111,30 +40,38 @@ Android 模拟器:
flutter run -d <emulator-id> --dart-define=RNB_API_BASE=<emulator-api-base-url>
```
## 验证
后端:
同一局域网内的 Android 真机:
```bash
cd report-notebooklm-api
source .venv/bin/activate
pytest -q
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-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)。
已实现:
产品 SSOTmall-docs 里的 report-notebooklm 文档,快照日期:2026-06-03
- 五个底部标签页:推荐、研报、机构、听单、我的
- 基于 API 的信息流、研报列表、机构列表、听单、机构详情和研报详情。
- 用于内联模块和「卡片 + 页面」模块的模块渲染器注册表。
- 产品显示名 `研听`
- 登录、收藏、外链跳转确认、播放进度的本地 UI 占位。
仅限本机的笔记、私有路径、原始会话指针、个人 agent 工作流,都应放在被忽略的 `docs.jimme.local/``AGENTS.local.md` 里。
尚未实现:
- 真实鉴权。
- 真实的收藏 / 历史 / 收听记录同步。
- 真正可播放的音频流。
- 真实的外链事件写入。
- 生产 API 域名。
- 发布签名、最终图标和最终应用商店元信息。
@@ -1,5 +1,6 @@
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")
}
@@ -14,6 +15,10 @@ 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"
@@ -34,12 +39,6 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 544 B

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 442 B

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 721 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")
-269
View File
@@ -1,269 +0,0 @@
# 数据源流转说明 / 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
@@ -1,44 +0,0 @@
# 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
@@ -1,85 +0,0 @@
# 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
@@ -1,45 +0,0 @@
# 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
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# 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

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 B

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 B

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 68 B

+26
View File
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
import 'app.dart';
Future<Widget> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
return const ReportNotebooklmApp();
}
+493
View File
@@ -0,0 +1,493 @@
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',
);
}
}
@@ -3,8 +3,10 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/models.dart';
import '../repositories/report_repository.dart';
import '../state/report_query.dart';
abstract class ReportDataSource {
abstract class ReportDataSource extends ReportRepository {
Future<List<ReportCardModel>> recommended();
Future<List<ReportCardModel>> reports();
Future<List<Institution>> institutions();
@@ -12,9 +14,66 @@ abstract class ReportDataSource {
Future<List<AudioItem>> listen();
Future<ReportDetail> reportDetail(String reportId);
Future<ModuleDetail> moduleDetail(String reportId, String moduleId);
@override
Future<List<ReportCardModel>> getRecommended({String? topic}) async {
final items = await recommended();
if (topic == null || topic == '全部') return items;
return items.where((item) => item.topics.contains(topic)).toList();
}
@override
Future<List<ReportCardModel>> getReports(ReportQuery query) async {
final currentSearch = query.search.trim().toLowerCase();
final items = await reports();
final filtered = items.where((item) {
final haystack =
'${item.titleCn} ${item.subtitleCn} ${item.oneLiner} '
'${item.institution.nameCn} ${item.institution.nameEn} '
'${item.topics.join(' ')}'
.toLowerCase();
if (currentSearch.isNotEmpty && !haystack.contains(currentSearch)) {
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;
}
@override
Future<ReportDetail> getReportDetail(String reportId) =>
reportDetail(reportId);
@override
Future<List<Institution>> getInstitutions() => institutions();
@override
Future<Institution> getInstitutionDetail(String institutionId) =>
institutionDetail(institutionId);
@override
Future<List<AudioItem>> getListenItems() => listen();
@override
Future<ModuleDetail> getModuleDetail(String reportId, String moduleId) =>
moduleDetail(reportId, moduleId);
}
class RnbApiDataSource implements ReportDataSource {
class RnbApiDataSource extends ReportDataSource {
RnbApiDataSource({
http.Client? client,
this.baseUrl = const String.fromEnvironment('RNB_API_BASE'),
@@ -72,6 +131,8 @@ class RnbApiDataSource implements ReportDataSource {
@override
Future<ModuleDetail> moduleDetail(String reportId, String moduleId) async {
return ModuleDetail.fromJson(await _get('/reports/$reportId/modules/$moduleId'));
return ModuleDetail.fromJson(
await _get('/reports/$reportId/modules/$moduleId'),
);
}
}
+96
View File
@@ -0,0 +1,96 @@
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
@@ -0,0 +1,69 @@
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);
});
@@ -18,12 +18,16 @@ List<String> asStringList(Object? value) {
JsonMap asMap(Object? value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.map((key, val) => MapEntry(key.toString(), val));
if (value is Map) {
return value.map((key, val) => MapEntry(key.toString(), val));
}
return const {};
}
List<JsonMap> asMapList(Object? value) {
if (value is List) return value.map(asMap).where((item) => item.isNotEmpty).toList();
if (value is List) {
return value.map(asMap).where((item) => item.isNotEmpty).toList();
}
return const [];
}
@@ -46,6 +50,7 @@ class Institution {
required this.id,
required this.nameCn,
this.nameEn = '',
this.logoUrl = '',
this.institutionType = '',
this.sourceTier = '',
this.websiteUrl = '',
@@ -60,6 +65,7 @@ class Institution {
final String id;
final String nameCn;
final String nameEn;
final String logoUrl;
final String institutionType;
final String sourceTier;
final String websiteUrl;
@@ -75,6 +81,7 @@ class Institution {
id: asString(json['institution_id']),
nameCn: asString(json['name_cn']),
nameEn: asString(json['name_en']),
logoUrl: asString(json['logo_url']),
institutionType: asString(json['institution_type']),
sourceTier: asString(json['source_tier']),
websiteUrl: asString(json['website_url']),
@@ -83,9 +90,9 @@ class Institution {
latestReportAt: json['latest_report_at']?.toString(),
credibilityNote: asString(json['credibility_note']),
introCn: asString(json['intro_cn']),
recentReports: asMapList(json['recent_reports'])
.map(ReportCardModel.fromJson)
.toList(),
recentReports: asMapList(
json['recent_reports'],
).map(ReportCardModel.fromJson).toList(),
);
}
}
@@ -161,7 +168,10 @@ class AudioItem {
audioId: asString(json['audio_id']),
reportId: asString(json['report_id']),
titleCn: asString(json['title_cn']),
reportTitleCn: asString(json['report_title_cn'], asString(json['title_cn'])),
reportTitleCn: asString(
json['report_title_cn'],
asString(json['title_cn']),
),
durationSec: asInt(json['duration_sec']),
institution: Institution.fromJson(asMap(json['institution'])),
releasedAt: json['released_at']?.toString(),
+70
View File
@@ -0,0 +1,70 @@
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();
});
@@ -0,0 +1,16 @@
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);
}
}
@@ -0,0 +1,12 @@
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);
}
@@ -0,0 +1,94 @@
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
@@ -0,0 +1,176 @@
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
@@ -0,0 +1,168 @@
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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,415 @@
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)}';
}
@@ -1,11 +1,18 @@
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 '../../../theme/wise_tokens.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/states.dart';
typedef StartModuleAudio =
void Function(
@@ -48,7 +55,7 @@ class ModuleRendererRegistry {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ModuleHeader(module: module),
const SizedBox(height: WiseSpacing.x4),
const SizedBox(height: YantingSpacing.x3),
_contentFor(
context,
type: module.type,
@@ -64,13 +71,14 @@ class ModuleRendererRegistry {
compact: module.renderMode != 'inline',
),
if (module.hasDetailPage) ...[
const SizedBox(height: WiseSpacing.x4),
const SizedBox(height: YantingSpacing.x3),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
child: AppButton(
onPressed: openDetail,
icon: const Icon(Icons.open_in_new),
label: const Text('查看详情'),
icon: AppIcons.externalLink,
kind: AppButtonKind.ghost,
label: '查看详情',
),
),
],
@@ -151,7 +159,7 @@ class ModuleRendererRegistry {
}
}
class ModuleDetailPage extends StatefulWidget {
class ModuleDetailPage extends HookConsumerWidget {
const ModuleDetailPage({
required this.reportId,
required this.module,
@@ -168,55 +176,87 @@ class ModuleDetailPage extends StatefulWidget {
final ModuleRendererRegistry registry;
@override
State<ModuleDetailPage> createState() => _ModuleDetailPageState();
Widget build(BuildContext context, WidgetRef ref) {
final retryCount = useState(0);
final future = useMemoized(
() => dataSource.moduleDetail(reportId, module.id),
[dataSource, reportId, module.id, 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: Text(module.titleCn),
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
? Center(
child: AppButton(
onPressed: () => retryCount.value++,
kind: AppButtonKind.ghost,
label: snapshot.error.toString(),
),
)
: _ModuleDetailContent(
detail: snapshot.data!,
report: report,
registry: registry,
),
);
}
}
class _ModuleDetailPageState extends State<ModuleDetailPage> {
late Future<ModuleDetail> future = widget.dataSource.moduleDetail(
widget.reportId,
widget.module.id,
);
class _ModuleDetailContent extends StatelessWidget {
const _ModuleDetailContent({
required this.detail,
required this.report,
required this.registry,
});
final ModuleDetail detail;
final ReportDetail report;
final ModuleRendererRegistry registry;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.module.titleCn)),
body: FutureBuilder<ModuleDetail>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
),
);
}
final detail = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(WiseSpacing.x4),
padding: const EdgeInsets.fromLTRB(
YantingSpacing.x4,
4,
YantingSpacing.x4,
16,
),
children: [
Text(
detail.titleCn,
style: YantingText.sectionTitle.copyWith(fontSize: 21),
),
const SizedBox(height: YantingSpacing.x2),
AppCard(
child: widget.registry.page(
child: registry.page(
context,
detail.type,
detail.content,
report: widget.report,
report: report,
),
),
const SizedBox(height: WiseSpacing.x3),
Text(
'缓存版本 ${detail.cacheVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: YantingSpacing.x3),
Text('缓存版本 ${detail.cacheVersion}', style: YantingText.meta),
],
);
},
),
);
}
}
@@ -232,7 +272,7 @@ class _ModuleHeader extends StatelessWidget {
Expanded(
child: Text(
module.titleCn,
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
),
if (module.layer.isNotEmpty)
@@ -261,12 +301,12 @@ class _BasicInfo extends StatelessWidget {
payload['summary_cn'],
asString(payload['scope_cn'], report?.oneLiner ?? ''),
),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
),
const SizedBox(height: WiseSpacing.x2),
const SizedBox(height: YantingSpacing.x2),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
for (final topic in topics) AppBadge(text: topic),
if (report?.releasedAt != null)
@@ -285,14 +325,21 @@ class _CoreInsights extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final points = asMapList(payload['points']);
if (points.isEmpty) return _TextLines(payload: payload);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final point in points)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x3),
Container(
margin: const EdgeInsets.only(bottom: YantingSpacing.x3),
padding: const EdgeInsets.all(YantingSpacing.x3),
decoration: BoxDecoration(
color: colors.background,
border: Border.all(color: colors.border),
borderRadius: BorderRadius.circular(YantingRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -300,11 +347,8 @@ class _CoreInsights extends StatelessWidget {
text: _kindLabel(asString(point['kind'])),
kind: _kindBadge(asString(point['kind'])),
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(point['text']),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: YantingSpacing.x1),
Text(asString(point['text']), style: YantingText.body),
],
),
),
@@ -321,19 +365,17 @@ class _SourceCompliance extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final institution = report?.institution;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['source_note']).isNotEmpty)
Text(
asString(payload['source_note']),
style: Theme.of(context).textTheme.bodyMedium,
),
Text(asString(payload['source_note']), style: YantingText.body),
if (institution != null) ...[
const SizedBox(height: WiseSpacing.x4),
Text('发布机构', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2),
const SizedBox(height: YantingSpacing.x4),
Text('发布机构', style: YantingText.cardTitle.copyWith(fontSize: 17)),
const SizedBox(height: YantingSpacing.x2),
_InfoLine(label: '机构名称', value: institution.nameCn),
if (institution.nameEn.isNotEmpty)
_InfoLine(label: '英文名称', value: institution.nameEn),
@@ -357,25 +399,21 @@ class _SourceCompliance extends StatelessWidget {
_InfoLine(label: '说明', value: institution.introCn),
],
if (asString(payload['copyright_cn']).isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x4),
Text(
asString(payload['copyright_cn']),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: YantingSpacing.x4),
Text(asString(payload['copyright_cn']), style: YantingText.meta),
],
const SizedBox(height: WiseSpacing.x3),
const SizedBox(height: YantingSpacing.x3),
DecoratedBox(
decoration: BoxDecoration(
color: const Color(0x109A6500),
borderRadius: BorderRadius.circular(WiseRadius.sm),
color: colors.background,
border: Border.all(color: colors.border),
borderRadius: BorderRadius.circular(YantingRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3),
padding: const EdgeInsets.all(YantingSpacing.x3),
child: Text(
asString(payload['disclaimer'], '本内容为公开/授权研报的结构化解读,不构成投资建议。'),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: WiseColors.warning),
style: YantingText.meta.copyWith(color: colors.warning),
),
),
),
@@ -392,20 +430,21 @@ class _InfoLine extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x2),
padding: const EdgeInsets.only(bottom: YantingSpacing.x2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.ink700),
style: YantingText.badge.copyWith(
color: colors.mutedForeground,
),
const SizedBox(height: WiseSpacing.x1),
Text(value, style: Theme.of(context).textTheme.bodyMedium),
),
const SizedBox(height: YantingSpacing.x1),
Text(value, style: YantingText.body),
],
),
);
@@ -460,17 +499,16 @@ class _InstitutionModule extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
final name = asString(payload['name_cn'], report?.institution.nameCn ?? '');
return Row(
children: [
const Icon(Icons.account_balance_outlined, color: WiseColors.primary),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: Text(name, style: Theme.of(context).textTheme.bodyMedium),
),
Icon(AppIcons.bank, color: colors.foreground),
const SizedBox(width: YantingSpacing.x2),
Expanded(child: Text(name, style: YantingText.body)),
Text(
'${asInt(payload['report_count'], report?.institution.reportCount ?? 0)}',
style: Theme.of(context).textTheme.bodySmall,
style: YantingText.meta,
),
],
);
@@ -494,19 +532,15 @@ class _SectionsModule extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (summary.isNotEmpty)
Text(summary, style: Theme.of(context).textTheme.bodyMedium),
if (summary.isNotEmpty) Text(summary, style: YantingText.body),
for (final section in sections) ...[
const SizedBox(height: WiseSpacing.x3),
const SizedBox(height: YantingSpacing.x3),
Text(
asString(section['heading']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(section['body']),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: YantingSpacing.x1),
Text(asString(section['body']), style: YantingText.body),
],
],
);
@@ -521,45 +555,45 @@ class _KeyDataModule extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
if (compact) return _Preview(payload: payload);
final rows = asMapList(payload['rows']);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final row in rows)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
Container(
margin: const EdgeInsets.only(bottom: YantingSpacing.x3),
padding: const EdgeInsets.all(YantingSpacing.x3),
decoration: BoxDecoration(
color: colors.secondary,
borderRadius: BorderRadius.circular(YantingRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(row['metric']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
_valueWithUnit(row),
style: Theme.of(context).textTheme.bodyLarge,
),
if (asString(
row['judgment'],
asString(row['importance']),
).isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x1),
Text(asString(row['metric']), style: YantingText.meta),
const SizedBox(height: 6),
Text(
asString(row['judgment'], asString(row['importance'])),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body.copyWith(color: colors.foreground),
),
],
if (asString(row['importance']).isNotEmpty &&
asString(row['importance']) !=
asString(row['judgment'])) ...[
const SizedBox(height: WiseSpacing.x1),
),
),
const SizedBox(width: YantingSpacing.x2),
Text(
asString(row['importance']),
style: Theme.of(context).textTheme.bodySmall,
_valueWithUnit(row),
textAlign: TextAlign.right,
style: YantingText.cardTitle.copyWith(
fontSize: 17,
fontFeatures: YantingTypographyFeatures.tabularNums,
),
),
],
],
),
),
@@ -581,33 +615,78 @@ class _TimelineModule extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final event in events)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
for (var index = 0; index < events.length; index++)
_TimelineEntry(
event: events[index],
isLast: index == events.length - 1,
),
],
);
}
}
class _TimelineEntry extends StatelessWidget {
const _TimelineEntry({required this.event, required this.isLast});
final JsonMap event;
final bool isLast;
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 9,
height: 9,
margin: const EdgeInsets.only(top: 6),
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
),
if (!isLast)
Expanded(
child: Container(
width: 1,
margin: const EdgeInsets.symmetric(vertical: 4),
color: colors.border,
),
),
],
),
const SizedBox(width: YantingSpacing.x2),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: YantingSpacing.x3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(event['date']).isNotEmpty)
Text(
asString(event['date']),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.primary),
style: YantingText.meta.copyWith(
color: colors.foreground,
fontWeight: FontWeight.w600,
),
const SizedBox(height: WiseSpacing.x1),
),
const SizedBox(height: YantingSpacing.x1),
Text(
asString(event['event']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(event['impact']),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: YantingSpacing.x1),
Text(asString(event['impact']), style: YantingText.body),
],
),
),
),
],
),
);
}
}
@@ -627,29 +706,23 @@ class _StudyGuideModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['intro_cn']).isNotEmpty)
Text(
asString(payload['intro_cn']),
style: Theme.of(context).textTheme.bodyMedium,
),
Text(asString(payload['intro_cn']), style: YantingText.body),
for (final item in faqs)
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text(asString(item['question'])),
title: Text(asString(item['question']), style: YantingText.body),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
asString(item['answer']),
style: Theme.of(context).textTheme.bodyMedium,
),
child: Text(asString(item['answer']), style: YantingText.body),
),
],
),
if (glossary.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x3),
const SizedBox(height: YantingSpacing.x3),
Wrap(
spacing: WiseSpacing.x2,
runSpacing: WiseSpacing.x2,
spacing: YantingSpacing.x2,
runSpacing: YantingSpacing.x2,
children: [
for (final item in glossary)
AppBadge(
@@ -679,27 +752,24 @@ class _StructureGraphModule extends StatelessWidget {
children: [
Text(
asString(payload['root']),
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x3),
const SizedBox(height: YantingSpacing.x3),
for (final node in nodes)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(node['label']),
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x1),
const SizedBox(height: YantingSpacing.x1),
for (final child in asStringList(node['children']))
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1),
child: Text(
child,
style: Theme.of(context).textTheme.bodyMedium,
),
padding: const EdgeInsets.only(bottom: YantingSpacing.x1),
child: Text(child, style: YantingText.body),
),
],
),
@@ -724,18 +794,18 @@ class _RelatedSourcesModule extends StatelessWidget {
children: [
for (final item in items)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(item['title']),
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x1),
const SizedBox(height: YantingSpacing.x1),
Text(
asString(item['summary_cn'], asString(item['source_name'])),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
),
],
),
@@ -756,6 +826,7 @@ class _DifferentiatedViewModule extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
if (compact) return _Preview(payload: payload);
final items = asMapList(payload['divergences']);
return Column(
@@ -763,40 +834,40 @@ class _DifferentiatedViewModule extends StatelessWidget {
children: [
for (final item in items)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x4),
padding: const EdgeInsets.only(bottom: YantingSpacing.x4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(item['topic']),
style: Theme.of(context).textTheme.titleMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: WiseSpacing.x2),
const SizedBox(height: YantingSpacing.x2),
if (asString(item['consensus_view']).isNotEmpty) ...[
Text(
'常见观点',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.ink700),
style: YantingText.badge.copyWith(
color: colors.mutedForeground,
),
const SizedBox(height: WiseSpacing.x1),
),
const SizedBox(height: YantingSpacing.x1),
Text(
asString(item['consensus_view']),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
),
const SizedBox(height: WiseSpacing.x2),
const SizedBox(height: YantingSpacing.x2),
],
if (asString(item['report_position']).isNotEmpty) ...[
Text(
'报告观点',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.primary),
style: YantingText.badge.copyWith(
color: colors.foreground,
),
const SizedBox(height: WiseSpacing.x1),
),
const SizedBox(height: YantingSpacing.x1),
Text(
asString(item['report_position']),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
),
],
],
@@ -815,6 +886,7 @@ class _WeaknessesModule extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
if (compact) return _Preview(payload: payload);
final items = asMapList(payload['items']);
final verificationNotes = asStringList(payload['verification_notes']);
@@ -827,60 +899,49 @@ class _WeaknessesModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (asString(payload['disclaimer_cn']).isNotEmpty)
Text(
asString(payload['disclaimer_cn']),
style: Theme.of(context).textTheme.bodySmall,
),
Text(asString(payload['disclaimer_cn']), style: YantingText.meta),
for (final item in items)
Padding(
padding: const EdgeInsets.only(
top: WiseSpacing.x3,
bottom: WiseSpacing.x2,
top: YantingSpacing.x3,
bottom: YantingSpacing.x2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
asString(item['topic']),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: WiseSpacing.x1),
Text(
asString(item['weakness']),
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.cardTitle.copyWith(fontSize: 17),
),
const SizedBox(height: YantingSpacing.x1),
Text(asString(item['weakness']), style: YantingText.body),
],
),
),
if (verificationNotes.isNotEmpty || counterEvidence.isNotEmpty) ...[
const SizedBox(height: WiseSpacing.x2),
const SizedBox(height: YantingSpacing.x2),
DecoratedBox(
decoration: BoxDecoration(
color: const Color(0x109A6500),
borderRadius: BorderRadius.circular(WiseRadius.sm),
color: colors.warningSoft.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(YantingRadius.sm),
),
child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x3),
padding: const EdgeInsets.all(YantingSpacing.x3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'需要继续验证',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: WiseColors.warning),
style: YantingText.badge.copyWith(color: colors.warning),
),
const SizedBox(height: WiseSpacing.x1),
const SizedBox(height: YantingSpacing.x1),
for (final note
in verificationNotes.isNotEmpty
? verificationNotes
: counterEvidence)
Padding(
padding: const EdgeInsets.only(bottom: WiseSpacing.x1),
child: Text(
note,
style: Theme.of(context).textTheme.bodySmall,
),
padding: const EdgeInsets.only(bottom: YantingSpacing.x1),
child: Text(note, style: YantingText.meta),
),
],
),
@@ -907,15 +968,11 @@ class _Preview extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (headline.isNotEmpty)
Text(headline, style: Theme.of(context).textTheme.bodyMedium),
if (headline.isNotEmpty) Text(headline, style: YantingText.body),
for (final item in highlights.take(3))
Padding(
padding: const EdgeInsets.only(top: WiseSpacing.x1),
child: Text(
'$item',
style: Theme.of(context).textTheme.bodySmall,
),
padding: const EdgeInsets.only(top: YantingSpacing.x1),
child: Text('$item', style: YantingText.meta),
),
],
);
@@ -938,7 +995,7 @@ class _TextLines extends StatelessWidget {
.join('\n');
return Text(
values.isEmpty ? '该模块暂无可展示内容。' : values,
style: Theme.of(context).textTheme.bodyMedium,
style: YantingText.body,
);
}
}
@@ -955,7 +1012,7 @@ class _FallbackModule extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppBadge(text: '未知模块:$type', kind: BadgeKind.warning),
const SizedBox(height: WiseSpacing.x2),
const SizedBox(height: YantingSpacing.x2),
_Preview(payload: payload),
],
);
+351
View File
@@ -0,0 +1,351 @@
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
@@ -0,0 +1,156 @@
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
@@ -0,0 +1,221 @@
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,
),
);
}
@@ -0,0 +1,201 @@
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,
),
),
),
),
],
);
}
}
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,243 @@
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,
),
),
),
);
}
}

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