Compare commits
15 Commits
6c72b7d048
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f28a64e4f | |||
| 6c943f8394 | |||
| 6a7fa5a067 | |||
| 544468f207 | |||
| ac794ae58a | |||
| af865b13fb | |||
| 33d04a5545 | |||
| c5288f397d | |||
| 9727b906c6 | |||
| b4272b5ec9 | |||
| e2554edfab | |||
| e93356e849 | |||
| a76ea8dd07 | |||
| 4a632ba60f | |||
| 634ae98dec |
@@ -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
|
||||
|
||||
@@ -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:`.
|
||||
@@ -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 和运维手册。
|
||||
产品 SSOT:mall-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)。
|
||||
已实现:
|
||||
|
||||
产品 SSOT:mall-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")
|
||||
@@ -1,269 +0,0 @@
|
||||
# 数据源流转说明 / Data Source Flow
|
||||
|
||||
这是一份交接快照,不是产品唯一真源(SSOT)。
|
||||
|
||||
产品 SSOT:mall-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 源清单与发布频率(补齐)
|
||||
|
||||
以下为 SSOT(vision-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. Morgan(AM + PWM) | 资产配置展望 | 年 / 不定期 |
|
||||
| Bloomberg Intelligence | 跨资产 | 不定期 |
|
||||
| WisdomTree(EU + 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 类 artifact,15 成功、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`,以及内部关联 ID(notebook / 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 存 OSS,MySQL 仅存 `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/触发节奏"。 |
|
||||
|
||||
**内容量门槛(非频率,但相关)**:
|
||||
|
||||
- 开发期种子:10–20 条 Report / 5–8 个 Institution / 3–5 条带音频。
|
||||
- 上线前首批:30–50 条已审核研报解读,≥10 条带音频。
|
||||
|
||||
**仍待补的缺口(建议下一步处理)**:
|
||||
|
||||
1. **研听生产 cadence(C 层)**:每篇研报的复读周期、每天/每周产量、生产 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 存引用键。
|
||||
- 缓存 → Redis(feed/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`
|
||||
- 产品 SSOT:mall-docs report-notebooklm 文档(数据源清单、构建 brief、数据模型契约、NotebookLM 能力实验报告)。
|
||||
@@ -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. |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -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: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
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 |
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
Future<Widget> bootstrap() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
return const ReportNotebooklmApp();
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||