asset-light 文档索引
这份文档集解决什么问题
- 如果你是第一次学 Rust:按顺序阅读 Rust 学习指南,并在本项目里完成每章的小练习。
- 如果你要维护/扩展本项目:优先阅读项目技术文档,了解模块边界、数据流、数据库与调试方式。
- 如果你在回看需求与设计:使用现有的 BMM 规划/设计文档作为“为什么做”和“要做到什么程度”的依据。
快速导航
- Rust 学习指南(循序渐进、面向 JS/TS/Java 背景):
docs/rust-guide/README.md - 项目技术文档(面向开发与维护):
docs/technical/README.md
BMM 规划/设计文档(已有)
- 产品愿景与范围:
docs/product-brief-asset-light-2025-12-20.md - 需求说明(PRD):
docs/prd-asset-light-2025-12-20.md - UI 设计:
docs/ui-design-asset-light-2025-12-20.md - 技术架构(早期版本,供对齐思路):
docs/architecture-asset-light-2025-12-20.md - 测试设计:
docs/test-design-asset-light-2025-12-20.md - Epic / Story 拆分:
docs/epics-and-stories-final-2025-12-20.md - 实现就绪检查:
docs/implementation-readiness-2025-12-20.md - 工作流状态:
docs/bmm-workflow-status.yaml
推荐阅读顺序(第一次学 Rust)
- 先看
docs/rust-guide/README.md的学习路线与章节说明。 - 跑通项目(
cargo run),把应用窗口跑起来。 - 从“模块结构/所有权/错误处理”三章开始边读边改,形成 Rust 心智模型。
- 再进入“rusqlite 数据库”和 “Dioxus UI 与状态”的实战章节。
文档网站(GitHub Pages + mdBook)
本仓库提供 mdBook 构建配置,可将 docs/ 发布为静态网站:
- 本地预览:
- 安装
mdbook后运行:mdbook serve - 默认会在本地启动预览并自动刷新
- 安装
- 线上发布(GitHub Actions):
- push 到
master/main且变更包含docs/**时,会自动构建并推送到docs分支 - 需要在 GitHub 仓库设置里启用 Pages(Source 选择
docs分支)
- push 到
文档约定
- 以本仓库代码为准:若规划/设计文档与当前实现存在差异,以代码为最终事实,并在技术文档中说明差异与原因。
- 每章都带练习:指南会尽量提供可以在本项目中直接完成的最小练习(可通过编译或 UI 行为验证)。
Rust 学习指南(结合 asset-light 项目)
目标读者
你熟悉 JS/TS/Java 前后端技术栈,但第一次系统学习 Rust,并希望通过 asset-light 这个真实项目建立 Rust 心智模型、掌握工程化工作流与常见库(Dioxus、rusqlite、serde、chrono、uuid、rust_decimal 等)。
使用方式(推荐)
- 每章 30-90 分钟:先读“概念与对照”,再做“本项目练习”。
- 边读边改:学习 Rust 最有效的方式是不断经历「编译器报错 → 理解原因 → 修正设计」。
- 以小步提交为节奏:每次只做一个小改动,保证能
cargo check通过(或至少明确当前失败原因)。
章节目录(循序渐进)
- 学习路线与心智模型(从 JS/Java 到 Rust)
- 工具链与 Cargo 工作流(你每天会用到的命令)
- crate / module / 可见性(看懂项目结构与
mod) - 所有权与借用(Rust 的“语法税”从哪里来)
- 数据建模:struct / enum / derive / Option(结合本项目模型)
- 错误处理:Result / ? / thiserror(让代码可维护)
- SQLite 与 rusqlite:Repository 模式与类型转换
- Dioxus 入门:组件、props、Signal、路由与数据流
- 练习清单与进阶路线(把 Rust 用熟)
你会在这个项目里学到什么(按能力分层)
- 语言层:所有权/借用、pattern matching、trait 的基本用法、Option/Result、宏(derive)。
- 工程层:Cargo 工作流、模块边界、错误类型设计、单元测试/集成测试思路。
- 项目层:本地 SQLite、Repository、Dioxus UI、全局状态与数据加载策略。
推荐的“最小跑通”验证
- 能
cargo run启动桌面应用。 - 能在“资产管理”新增一条资产,刷新列表并落库。
- 能发起一次盘点并在“历史”看到快照。
- 能在“配置”切换激活方案,并在“首页”看到偏离提示变化。
01 学习路线与心智模型(从 JS/TS/Java 到 Rust)
本章目标
- 建立一套可落地的 Rust 学习路线(不是“看完语法”,而是“能独立改项目”)。
- 用你熟悉的 JS/TS/Java 心智模型对照理解:Rust 到底“难”在哪里、值得在哪里。
- 结合
asset-light这类“带 UI + 本地 DB”的项目,明确你要掌握哪些核心能力。
Rust 与你熟悉语言的差异(快速对照)
- 内存管理方式
- JS/TS/Java:GC(垃圾回收)+ 运行时开销可控但不可见
- Rust:编译期所有权(Ownership)+ 零成本抽象(把“内存规则”变成编译器校验)
- 错误处理
- JS:throw / try-catch(运行时)
- Java:checked/unchecked exception(类型系统里有一部分)
- Rust:
Result<T, E>/Option<T>(错误与缺失值默认都是“类型的一部分”)
- 并发与共享
- JS:单线程事件循环 + message passing(Web Worker 等)
- Java:多线程 + 锁/并发结构
- Rust:多线程 + 借用规则 +
Send/Sync(类型系统约束并发安全)
- 抽象方式
- TS:结构类型 + 泛型 + union
- Java:名义类型 + interface/class + 泛型
- Rust:trait + 泛型 + enum(表达力很强,代价是需要更多“类型思维”)
你需要建立的 3 个 Rust 心智模型
1) “值 = 资源”,所有权是默认规则
在 Rust 里,String、Vec<T>、数据库连接、文件句柄都可以视为“资源”。
资源默认只能有一个拥有者,当拥有者离开作用域,资源就被释放。
你会经常遇到“move(移动语义)”:
- 把一个
String赋给另一个变量,默认是移动,旧变量不能再用。 - 想继续用?你要么传引用(借用),要么显式
clone()(付出复制成本)。
2) “类型 = 约束”,把不变量写进类型
Rust 鼓励把业务不变量写进类型与 API:
Option<T>:可能为空(不再用null/undefined兜底)Result<T, E>:可能失败(错误必须被处理或传播)enum:用“可枚举的状态集合”替代“字符串魔法值”
在 asset-light 里,分类体系就体现得很强:
Category:资产大类(稳定枚举 + 稳定编码)AssetScope:口径(投组 vs 净资产项)VehicleType:工具类型(载体维度)AssetSubCategory:子资产类别(稳定编码)
对应文件:src/models/category.rs、src/models/asset_scope.rs、src/models/vehicle_type.rs、src/models/asset_sub_category.rs
3) “编译器 = 教练”,让报错成为学习反馈
Rust 的学习体验非常像“被严格的教练带着做题”:
- 你写的代码不符合规则,编译器会阻止你继续。
- 你一开始会觉得慢,但当你熟悉模式后,bug 会大幅减少。
建议把 Rust 报错当作“反馈提示”而不是“障碍”:
- 先读第一段总结(通常就告诉你核心矛盾)
- 再看它标出的两处代码位置(borrow 的来源与冲突点)
- 最后再决定:借用?clone?重构 API?
结合 asset-light 的学习路线(推荐顺序)
这份指南不是按语法目录写的,而是按“能把项目改起来”写的。
第 0 步:先跑起来(30 分钟)
- 运行入口:
src/main.rs - 根组件:
src/app.rs - 路由:
src/router.rs - 数据库初始化:
src/db/mod.rs→src/db/connection.rs
你要能回答:
- 应用从哪里启动?
- DB 什么时候初始化?DB 路径在哪里?
- 页面切换是谁控制的?
第 1 步:读懂模块结构(1-2 小时)
先把“文件夹就是模块”的结构读顺:
src/models/*(领域模型)src/db/*(Repository + 连接)src/pages/*(页面)src/components/*(UI 组件)src/state/*(全局状态)
第 2 步:啃下所有权/借用(2-5 小时,分多次)
这是 Rust 的核心门槛,也是一旦跨过就能持续受益的部分。
建议配合本项目里这两个“真实场景”来学:
Signal<AppState>的.read()/.write()(UI 状态读写)Mutex<Connection>+OnceLock<Database>(全局 DB 单例 + 可变共享)
第 3 步:掌握 DB 访问与类型转换(2-4 小时)
从“能读能写”入手,再讨论“优雅与健壮”:
AssetRepository:insert/update/find_allSnapshotRepository:create/find_allPlanRepository:create/update/set_active
对应目录:src/db/*
第 4 步:理解 Dioxus 的组件与数据流(2-4 小时)
你要能熟练定位:
- 页面:
src/pages/* - 组件:
src/components/* - 路由/导航:
src/router.rs、src/components/layout/sidebar.rs
本章练习(不改业务逻辑,先建立“地图”)
练习 A:从入口走一遍执行链
- 打开
src/main.rs,找到:db::init_database()dioxus::launch(app::App)
- 打开
src/db/mod.rs,确认:OnceLock<Database>是怎么实现“全局单例”的
- 打开
src/router.rs,确认:Route枚举定义了哪些页面
- 打开
src/components/layout/sidebar.rs,确认:- Sidebar 导航项和 Route 的对应关系
练习 B:找到 DB 路径逻辑
打开 src/db/connection.rs 的 Database::db_path(),回答:
- 默认 DB 文件落在哪里?
- 你如何通过环境变量指定 DB 路径?(提示:
ASSET_LIGHT_DB_PATH)
02 工具链与 Cargo 工作流(你每天会用到的命令)
本章目标
- 你能熟练使用 Cargo 完成开发闭环:编译、运行、检查、格式化、静态检查、测试。
- 你能读懂
Cargo.toml/Cargo.lock,知道依赖和 feature 怎么影响编译。 - 你能把“工具链”变成肌肉记忆,让学习 Rust 不被环境问题消耗。
必备工具(建议安装/开启)
- Rust toolchain:
rustup/cargo/rustc - IDE:VS Code +
rust-analyzer(或 JetBrains Rust) - 格式化:
rustfmt - 静态检查:
clippy
rustfmt/clippy通常随 toolchain 安装,但可能需要rustup component add rustfmt clippy。
Cargo 常用命令(按使用频率排序)
1) cargo check
- 只做类型检查,不产物最终二进制,速度快,适合频繁运行。
- 你写 Rust 的大多数错误,
cargo check都能第一时间告诉你。
2) cargo run
- 编译并运行当前 crate 的二进制(本项目会启动 Dioxus 桌面应用)。
- 常用参数:
cargo run --release:发布模式(优化后更快,但编译更慢)
3) cargo build
- 只编译不运行。
- 常用于 CI 或你想确认产物可生成。
4) cargo fmt
- 使用 rustfmt 自动格式化。
- 建议开启 IDE 保存时自动格式化,减少风格讨论成本。
5) cargo clippy
- Clippy 是“更严格”的 lint 工具,会给出很多“更 Rust”的建议。
- 你学习期非常适合用它纠正习惯(比如不必要的 clone、低效写法、可读性问题)。
6) cargo test
- 跑单元测试与集成测试。
- 本项目目前测试较少,但你可以把练习写成测试(学习效果很好)。
依赖与锁文件:Cargo.toml vs Cargo.lock
Cargo.toml:你声明“我需要什么依赖”Cargo.lock:Cargo 解析出的“我实际用的版本”(确保可复现构建)
在 asset-light 中,依赖定义见 Cargo.toml:
dioxus:UI 框架(desktop + router feature)rusqlite:SQLite 访问(bundled feature 便于本地开发)serde:序列化chrono:时间uuid:唯一 IDrust_decimal:金额/比例的精确计算thiserror:更优雅的错误类型(当前代码仍有进一步统一空间)
本项目开发中“最常用”的命令组合
开发循环(最推荐)
cargo fmtcargo checkcargo run
只验证质量(更严格)
cargo fmtcargo clippycargo test
环境变量(对调试非常有用)
1) ASSET_LIGHT_DB_PATH
用于指定 sqlite 文件路径,常用于:
- 临时跑一个干净的 DB
- 做手工验证而不污染真实数据
- 写测试时使用临时目录
实现位置:src/db/connection.rs → Database::db_path()
常见坑与解决思路
1) 第一次编译下载依赖很慢/失败
- 第一次
cargo build/cargo run会下载 crates。 - 如果网络环境不稳定,会出现解析/下载失败。
建议:
- 先确保能访问
crates.io(必要时配置代理) - 尽量让构建机/CI 具备稳定网络
2) 你改了很多文件,cargo run 很慢
- 先用
cargo check快速拿到类型错误反馈 - 修到
cargo check通过后再cargo run
3) “跑起来了但看不到数据”
这是本地 app 常见问题:
- 数据存在 DB 文件里,换了 DB 路径相当于换了数据源
- 或者迁移逻辑做了破坏性升级(清空数据)
排查:
- 是否设置了
ASSET_LIGHT_DB_PATH? src/db/connection.rs的迁移逻辑是否 drop 过表?
本章练习(把工具链练熟)
- 运行一次
cargo fmt,观察有哪些文件被格式化。 - 运行一次
cargo check,熟悉输出结构(警告/错误定位方式)。 - 用一个临时 DB 路径启动应用:
ASSET_LIGHT_DB_PATH=/tmp/asset-light-dev.db cargo run
然后在 UI 里新增资产,确认重新启动后数据仍存在该 DB 文件中。
03 crate / module / 可见性(看懂项目结构与 mod)
本章目标
- 你能从
src/main.rs一眼读懂“这个 crate 由哪些模块组成”。 - 你能理解
mod、use、pub、pub use的组合用法,并在项目里正确组织代码边界。 - 你能在新增文件/目录后让编译器“找到它”,避免常见的模块路径错误。
术语速览(用你熟悉的语言类比)
- crate:一个编译单元(类似一个 npm package / 一个 Maven 模块)
- module:crate 内部的命名空间(类似 TS 的 module/namespace + 文件系统约定)
- item 可见性:
pub决定“外部模块是否可用”(类似public/private,但粒度更细)
从 src/main.rs 读懂 crate 根
本项目的 crate root 是二进制入口:src/main.rs
你会看到类似这样的声明:
mod app;mod components;mod db;mod models;mod pages;mod router;mod services;mod state;mod utils;
这意味着:
- crate 顶层模块包括:
crate::app、crate::db、crate::models等 - 对应文件/目录通常是:
src/app.rs(单文件模块)src/db/mod.rs+src/db/*.rs(目录模块)
“目录模块”是怎么工作的(为什么需要 mod.rs)
Rust 的模块系统会把文件系统映射为模块树:
src/db/mod.rs对应模块:crate::dbsrc/db/asset_repo.rs对应模块:crate::db::asset_repo
在 src/db/mod.rs 中,通过 mod asset_repo; 把子模块挂载进来,随后可以:
- 在
db/mod.rs内部use asset_repo::... - 或者
pub use asset_repo::AssetRepository;作为对外 API(更常见)
use / pub / pub use 的组合(工程里最常用)
1) use:在当前模块引入名字
#![allow(unused)] fn main() { use crate::db::AssetRepository; }
仅影响当前模块的可读性,不改变对外可见性。
2) pub:让 item 对外可见
#![allow(unused)] fn main() { pub struct AssetRepository; }
其他模块才能通过 crate::db::asset_repo::AssetRepository 使用它。
3) pub use:重新导出(re-export)
这是大型项目里非常常见的“门面模式”:
- 内部可以有
crate::db::asset_repo、crate::db::plan_repo等细粒度模块 - 对外只暴露
crate::db::{AssetRepository, PlanRepository, SnapshotRepository}
你可以在 src/db/mod.rs 里看到类似写法:
pub use asset_repo::AssetRepository;pub use plan_repo::PlanRepository;
这样 pages 层就不需要了解 db 的文件拆分细节。
路径系统:crate:: / super:: / self::
这是初学者经常迷路的地方,建议记住这三种“稳定定位”:
crate::xxx:从 crate 根开始(最稳)super::xxx:从父模块开始(重构文件时容易受影响)self::xxx:当前模块(用于更明确的内部引用)
在 src/models/asset_sub_category.rs 里就有典型用法:
use super::Category;:从同目录的父模块models引入Category
常见报错与解决方式
1) file not found for module ...
原因通常是:
- 新增了
src/foo/bar.rs,但没有在src/foo/mod.rs写mod bar; - 或者
mod声明和文件名不一致
2) cannot find type/struct/function ... in this scope
原因通常是:
- 忘了
use ...引入 - 或者 item 没有
pub,跨模块访问被禁止
本章练习(在项目里动手巩固)
练习 A:画出模块树(建议真的画出来)
从 src/main.rs 出发,把顶层模块写出来,然后继续展开:
db:connection、asset_repo、plan_repo、snapshot_repomodels:asset、category、asset_scope、vehicle_type、asset_sub_categorycomponents:asset、plan、snapshot、analysis、layout、common
练习 B:新增一个 utils 子模块(不改业务)
- 新建文件:
src/utils/debug.rs - 在
src/utils/mod.rs增加:pub mod debug; - 在
debug.rs写一个小函数:pub fn dump_routes():打印路由列表(字符串即可)
- 在
src/main.rs或某个页面里临时调用它验证编译通过
你会经历一次完整的“新增模块 → 挂载 → 导入 → 使用”的流程。
04 所有权与借用(Rust 的“核心门槛”与核心价值)
本章目标
- 你能解释:为什么 Rust 不需要 GC 也能安全释放内存。
- 你能在真实代码里解决常见编译错误:move、借用冲突、生命周期相关提示。
- 你能理解
asset-light里两类典型“共享可变”场景:- UI 状态:
Signal<AppState>的.read()/.write() - DB 连接:
Mutex<Connection>+OnceLock<Database>
- UI 状态:
关键概念(先把话说清楚)
1) 所有权(Ownership)
- 每个值在任意时刻只有一个“拥有者”(owner)
- owner 离开作用域,值会被 drop(资源释放)
2) 移动(Move)
对“拥有资源”的类型(如 String、Vec<T>):
- 赋值 / 传参默认是 move
- move 之后,旧变量失效(防止“双重释放”)
3) 借用(Borrow)与引用(Reference)
&T:不可变借用(多读)&mut T:可变借用(单写)
Rust 的核心规则:
- 同一时间:
- 要么有任意多个
&T - 要么只有一个
&mut T
- 要么有任意多个
与 JS/Java 的直觉冲突点(你一定会遇到)
- “我只是传个参数,为啥变量就不能用了?”
因为你传的是 move(把所有权交出去了),不是引用。 - “我就想同时读写一下,为啥不行?”
因为 Rust 要在编译期保证没有数据竞争/悬垂引用。 - “我明明只是在 UI 里读 state,为啥又报借用冲突?”
因为.read()/.write()可能拿到内部借用句柄,作用域没结束就会冲突。
String vs &str(Rust 初学者最常见困惑)
String:拥有所有权、可增长、堆分配&str:字符串切片引用(通常指向String或静态字符串)
经验法则:
- API 接收参数时,优先用
&str(更通用、避免不必要的 clone) - 存储字段时,用
String(拥有数据,生命周期更简单)
在 asset-light 中的“共享可变”怎么做到安全
1) UI 全局状态:Signal<AppState>
文件:src/state/app_state.rs
provide_app_state()用use_context_provider注入一个Signal<AppState>- 组件内用
use_app_state()获取它 .read():拿到只读视图.write():拿到可写视图(通常是短作用域)
你在页面里经常看到类似写法:
let mut state = use_app_state();let assets = state.read().assets.clone();state.write().assets = assets;
为什么常见 clone()?
因为 UI 渲染往往需要拥有数据(或需要跨作用域),而借用规则不允许你把 &T 引用带出读取作用域。
学习阶段先接受 clone 的存在,等掌握后再优化。
2) DB 单例与连接:OnceLock<Database> + Mutex<Connection>
文件:src/db/mod.rs 与 src/db/connection.rs
OnceLock<Database>:保证数据库只初始化一次(全局单例)Database内部用Mutex<Connection>:- 互斥锁保证同一时间只有一个线程/调用在访问
Connection - 让“全局可变”在类型系统下变得安全
- 互斥锁保证同一时间只有一个线程/调用在访问
你会看到:
let conn = self.conn.lock().unwrap();
这行 unwrap() 在工程上可以进一步改进(见后续“错误处理”章节),但它体现了“锁拿到了才能继续用连接”的共享可变模式。
常见编译错误“翻译器”(带你读懂报错)
1) use of moved value
你把值 move 走了,然后又用旧变量。
解决思路(按优先级):
- 改成传引用:
fn foo(s: &str)/fn foo(v: &Vec<T>) - 必要时 clone:
let b = a.clone(); - 重新设计数据流:把 ownership 留在更高层
2) cannot borrow ... as mutable because it is also borrowed as immutable
同一个值同时存在不可变借用与可变借用。
解决思路:
- 缩小借用作用域:用代码块
{ ... }包住.read()的使用 - 先把数据拷贝出来再写:
let x = state.read().foo.clone(); state.write().foo = x; - 重构:拆成两个 signal/字段,减少冲突
3) 生命周期相关提示(先不硬啃)
学习期建议:
- 先用拥有类型(
String/Vec<T>)把功能做出来 - 当你开始做性能优化/减少 clone 时,再回头系统学习 lifetime 标注
本章练习(用真实代码练 borrow 思维)
练习 A:把 “读写同一个 state” 的作用域缩小
找一个页面(例如 src/pages/assets.rs)观察这种模式:
- 先
state.read()拿数据 - 后面
state.write()更新数据
尝试把读取逻辑包进一个更小的作用域块,确保你理解“借用结束点”:
{ let assets = state.read().assets.clone(); ... }- 再进行写入
目的不是改功能,而是让你建立“借用在何时结束”的直觉。
练习 B:识别哪些类型是 Copy,哪些会 move
在本项目里找几个字段:
Category(Copy:枚举,Clone, Copy)Uuid(非 Copy,通常 move / clone)Decimal(非 Copy,通常 clone 或引用)String(move 语义最典型)
你需要能解释:为什么 Category 可以随便传来传去,而 String 不行。
练习 C:理解为什么 DB 连接需要 Mutex
打开 src/db/connection.rs,回答:
- 如果没有
Mutex,我们如何在多个调用点安全地复用一个Connection? - 为什么 Rust 不允许“全局可变静态变量直接拿来改”?
05 数据建模:struct / enum / derive / Option(结合本项目模型)
本章目标
- 你能用 Rust 的类型系统把“业务不变量”表达清楚(避免到处塞字符串和魔法值)。
- 你能读懂并扩展
asset-light的领域模型(Asset / Snapshot / AllocationPlan)。 - 你能理解“稳定编码(code)”与“展示名(display_name)”分离的工程价值。
1. Rust 里怎么“建模”
1.1 struct:描述“一个东西长什么样”
Rust 的 struct 更像是:
- TS:
type Asset = { ... }+ 一些 helper function - Java:POJO + 静态工厂方法 + getter/setter(但 Rust 更倾向不可变/显式修改)
本项目的 Asset 定义在:src/models/asset.rs
它把“资产条目”拆成多个正交维度:
scope: AssetScope:口径(是否纳入再平衡)category: Category:资产大类(风险暴露维度)sub_asset_class: String:子资产类别稳定编码(例如EQ_BROAD)vehicle_type: VehicleType:工具类型(ETF/基金/股票等“持有载体”)
这四个字段非常关键:它们让 UI/分析/DB 都可以围绕同一套“结构化语义”工作。
1.2 enum:描述“一个东西只能是这几种之一”
Rust 的 enum 是“可穷举的联合类型”(Sum Type):
- TS:
type Category = "CASH" | "FI" | ...(但 TS 运行时还是字符串) - Java:
enum Category { ... }
Rust 的强项在于:match 必须覆盖所有分支,使得“漏处理”在编译期就能被抓住。
本项目大量使用 enum:
src/models/category.rs:Categorysrc/models/asset_scope.rs:AssetScopesrc/models/vehicle_type.rs:VehicleType
1.3 derive:把“样板代码”交给编译器生成
你会在模型上看到非常多的 #[derive(...)]:
Debug:方便打印/调试(println!("{:?}", x))Clone:允许显式复制(UI 层经常需要)Copy:轻量值语义复制(常用于小枚举,如Category)PartialEq/Eq/Hash:用于比较、作为 HashMap key 等Serialize/Deserialize:与 serde 结合做序列化(未来做导出/导入会很有用)
例子:Category 是 Copy 的(Clone, Copy),而 Asset 不是(包含 String、Decimal 等堆/大对象)。
2. 本项目的建模策略:稳定编码 vs 展示名
2.1 为什么需要稳定编码
UI 展示名可能会变(文案、翻译、缩写),但数据库里存的数据应该尽量稳定。
因此本项目通常采用:
- DB 存:稳定编码(
CASH/FI/EQ_BROAD/ETF…) - UI 展示:中文名/图标/颜色(
display_name()/icon()/color())
示例:Category 同时提供:
as_str():稳定编码(持久化)from_str():从编码解析(读库/兼容旧数据)display_name():展示名(UI)
对应实现见:src/models/category.rs
2.2 “子资产类别”为什么在 Asset 里是 String
你会注意到:
- 子资产类别标准枚举在:
src/models/asset_sub_category.rs(AssetSubCategory) - 但
Asset.sub_asset_class是String
这是一个典型工程权衡:
用 String 存储稳定编码让系统更“前向兼容”(未来增加子类别不必立刻升级所有数据结构),同时仍然可以用 AssetSubCategory 提供:
- 编码全集(
all()) - 按大类枚举(
all_for_category()) - 从 code 解析(
from_code()) - 校验编码属于哪个大类(
is_valid_for_category())
你可以把它理解为:“DB 里是稳定字符串协议,代码里用 enum 提供字典与校验”。
3. 结合代码:读懂 Asset / Snapshot / Plan
3.1 Asset(资产条目)
文件:src/models/asset.rs
重点:
Asset::new(...)是“构造器/工厂方法”,统一初始化id/created_at/updated_atnotes: Option<String>明确表达“可能没有备注”
典型构造方式(见测试代码):
- 子资产类别:
AssetSubCategory::CashMmf.code().to_string() - 金额:用
rust_decimal_macros::dec!()避免浮点误差
3.2 Snapshot(盘点快照)
文件:src/models/snapshot.rs
关键设计:
SnapshotItem会冗余存储asset_name/scope/category/sub_asset_class/vehicle_type
这样即使未来资产条目被改名/改分类,历史快照仍然能反映当时状态(可审计)。Snapshot::new(items)自动计算total_value
3.3 AllocationPlan(配置方案)
文件:src/models/plan.rs
关键设计:
Allocation.category: Category用 enum 表达“配置只能基于大类”target_percentage: Decimal表达比例,避免 f64 的误差get_target(&Category)提供便利查询(服务层/页面层都会用)
你可以把 Plan 理解成“目标权重向量”,而资产是“当前权重向量”,偏离分析就是两者差异。
对应偏离计算见:src/services/analysis_service.rs
4. 你需要掌握的建模技巧(从易到难)
4.1 用 enum 替代字符串魔法值
优先让业务状态成为 enum:
- 更可读
- 更可 refactor
- 更少“拼错字符串”的隐患
本项目里 DB 仍然需要字符串编码,但代码内部尽量用 enum 表达语义。
4.2 用 Option 表达“可缺失字段”
不要用空字符串 "" 代表“没有值”。
本项目里:
notes: Option<String>AllocationPlan.description: Option<String>
4.3 用 Display / FromStr 把“协议转换”集中管理
一个很实用的工程习惯:
- 解析/格式化逻辑不要散落在 Repository / UI 里
- 集中在模型自身(例如
Category::from_str、Category::as_str)
当前项目主要使用自定义的 from_str/as_str 方法;进阶阶段你可以把它们升级为实现标准 trait:
impl std::fmt::Display for Categoryimpl std::str::FromStr for Category
(Category 已经实现了 Display)
5. 本章练习(强烈建议做)
练习 A:给 VehicleType 实现 Display
当前 VehicleType 有 display_name(),但没有实现 Display。
你可以在 src/models/vehicle_type.rs 里增加:
impl std::fmt::Display for VehicleType { ... }
目标:让你习惯“把展示逻辑集中在类型上”,UI 里直接 {vehicle_type} 更自然。
练习 B:给 Asset 增加一个轻量校验方法 validate()
在 src/models/asset.rs 增加:
- 校验
sub_asset_class是否属于category(用AssetSubCategory::is_valid_for_category)
这会让你理解:
- 业务不变量写在模型里,而不是到处 scattered if
- 校验失败最好用
Result<(), E>表达(为下一章“错误处理”做铺垫)
练习 C(进阶):把 sub_asset_class: String 包装成 newtype
比如:
pub struct SubAssetClassCode(String);
好处:
- 更明确的语义(避免把任意 String 当成 code)
- 更容易集中实现校验/显示
代价:
- 需要更新 DB 读写与序列化/反序列化逻辑
建议你先做 A/B,熟练后再挑战 C。
06 错误处理:Result / ? / thiserror(让代码可维护)
本章目标
- 你能熟练使用
Option/Result/?来表达"可能失败/可能缺失"。 - 你能区分"可以 panic 的地方"和"必须返回错误的地方"。
- 你能看懂本项目的错误处理设计(已使用
thiserror定义统一的AppError)。
1. Rust 的错误处理哲学(和 JS/Java 的差别)
1.1 Rust 默认不鼓励用异常控制流程
Rust 当然也有 panic(相当于“不可恢复异常”),但日常工程里更推荐:
- 可预期失败:用
Result<T, E> - 可缺失值:用
Option<T>
这让函数签名本身就能告诉你“调用者需要处理什么情况”。
1.2 ? 运算符:把错误向上传播
当你拿到 Result<T, E>:
- 成功:取出
T - 失败:立刻
return Err(E)
这就是 ? 的含义。
理解 ? 会极大提升你写 Rust 的速度,因为你不需要层层 match。
2. Option vs Result:什么时候用哪个?
2.1 Option<T>:没有就是没有(不是“错误”)
例子:
notes: Option<String>:资产可以没有备注find_latest() -> Result<Option<Snapshot>, String>:查询可能成功但“没有数据”
这里“没有数据”不是错误,所以用 Option。
2.2 Result<T, E>:操作可能失败
例子:
- DB 访问失败(IO/SQL/锁/约束)
- 数据解析失败(
Uuid/Decimal/DateTime字符串不合法) - 业务校验失败(比例不等于 100,盘点次数超限等)
3. 本项目当前错误处理现状(你需要先读懂)
3.1 已实现:统一错误类型 AppError
本项目在 src/error.rs 中定义了统一的错误类型:
#![allow(unused)] fn main() { #[derive(Error, Debug)] pub enum AppError { #[error("数据库错误: {0}")] Database(#[from] rusqlite::Error), #[error("数据解析错误: {0}")] Parse(String), #[error("{resource}未找到: {id}")] NotFound { resource: &'static str, id: String }, #[error("{0}")] Business(String), #[error("IO错误: {0}")] Io(#[from] std::io::Error), #[error("数据库未初始化")] DatabaseNotInitialized, } pub type AppResult<T> = Result<T, AppError>; }
Repository 层(如 AssetRepository、PlanRepository、SnapshotRepository)现已返回 AppResult<T>,利用 ? 运算符自动转换 rusqlite::Error 为 AppError::Database。
3.2 待改进:row_to_xxx 仍使用 unwrap_or_default()
虽然 Repository 函数返回类型已升级,但在 row_to_asset 等内部方法中仍有:
Uuid::parse_str(&id).unwrap_or_default()Decimal::from_str(&value_str).unwrap_or_default()
这会把"脏数据"悄悄变成默认值,导致后续分析结果不可信。详见第 4 节的改进建议。
3.3 UI 层目前多用 eprintln! 打印错误
例如 src/pages/assets.rs 保存失败时:
eprintln!("保存资产失败: {}", e);
这在学习期 OK,但如果想做成"可用的产品",需要把错误反馈到 UI(AppState.error 或 toast)。
4. 当前设计与改进方向
4.1 已完成:统一错误类型 AppError
本项目已在 src/error.rs 定义了应用级错误:
Database(rusqlite::Error):数据库操作错误Parse(String):数据解析错误NotFound { resource, id }:资源未找到Business(String):业务规则错误Io(std::io::Error):IO 错误DatabaseNotInitialized:数据库未初始化
收益:
- DB 层保留了结构化错误(自动从
rusqlite::Error转换) - UI 层可以统一把
AppError格式化成用户可读文本 - 未来可做更细粒度处理(例如
NotFound走黄色提示,Database错误走红色)
4.2 待改进:不要用 unwrap_or_default() 静默吞掉解析失败
在 AssetRepository::row_to_asset 里,目前策略是:
Uuid::parse_str(&id).unwrap_or_default()Decimal::from_str(&value_str).unwrap_or_default()
这会把"脏数据"悄悄变成默认值,导致后续分析结果不可信。
更好的策略(从易到难):
- 学习期(低成本):用
expect("...")明确崩溃原因(至少别静默) - 工程期(推荐):把解析失败转成
AppError::Parse(...)向上传播
5. unwrap / expect / panic!:什么时候可以用?
经验规则(适合这个项目):
- 测试代码:可以用
unwrap()(失败就失败,快速定位) - “绝不可能失败”的地方:可以用
expect("不可能失败的原因说明") - 用户输入/DB/IO:不要 panic,应该返回
Result给上层处理
在本项目里,以下位置是“典型可改进点”:
Mutex::lock().unwrap():可用expect("数据库连接锁被 poison")至少增强可诊断性parse_from_rfc3339(...).unwrap_or_else(|_| Utc::now()):会把时间解析失败吞掉,建议改为错误传播或显式 fallback 记录日志
6. 本章练习(建议按顺序做)
练习 A:把一个 unwrap_or_default() 改成显式错误
选择一个 Repository(例如 AssetRepository::row_to_asset),把:
Uuid::parse_str(&id).unwrap_or_default()
改为:
- 学习期:
Uuid::parse_str(&id).expect("assets.id 必须是合法 uuid")
你会马上感受到"更早失败、更容易定位"。
练习 B:把 row_to_asset 的解析错误传播出去
目标:利用已有的 AppError::Parse,把 row_to_asset 中的 unwrap_or_default() 升级为结构化错误传播。
建议步骤:
- 修改
row_to_asset返回类型为Result<Asset, AppError>(而非rusqlite::Result<Asset>) - 把
Uuid::parse_str(&id).unwrap_or_default()改为:#![allow(unused)] fn main() { Uuid::parse_str(&id) .map_err(|_| AppError::parse(format!("无效的资产 ID: {}", id)))? } - 同理处理
Decimal::from_str、DateTime::parse_from_rfc3339等 - 调整调用处(
query_map的闭包)以适配新的返回类型
注:
AppError已定义在src/error.rs,Repository 已使用AppResult<T>。
练习 C:把错误展示到 UI(而不是只打印)
本项目 AppState 有 error: Option<String> 字段。你可以在:
- 写入失败时:
state.write().error = Some(e.to_string()) - 页面顶部:渲染一个小红条/Toast(先做最简单版本)
这会让你把错误处理从"工程内部"扩展到"产品体验"。
07 SQLite 与 rusqlite:Repository 模式与类型转换
本章目标
- 你能读懂
asset-light的数据库初始化/迁移/表结构。 - 你能理解 rusqlite 的基本用法:参数化查询、
prepare、query_map、类型转换。 - 你能看出当前实现的工程权衡点,并能做小步改进(例如事务、错误类型、测试)。
1. 本项目的 DB 初始化链路
1.1 初始化入口
入口:src/main.rs
- 启动时先调用
db::init_database() - 通过后才启动 Dioxus UI
DB 初始化逻辑:src/db/mod.rs
OnceLock<Database>保存全局单例Database::new()建立连接db.run_migrations()执行迁移db.ensure_default_plan()确保默认配置方案存在
1.2 DB 文件路径与环境变量
路径逻辑:src/db/connection.rs → Database::db_path()
- 支持
ASSET_LIGHT_DB_PATH环境变量(用于测试/临时 DB) - 默认用
dirs::data_local_dir(),在 macOS 通常会落到用户目录的 Application Support 下
建议你把它当作“调试开关”记住:
想要一个干净 DB,不必删真实数据,只要换一个 DB 路径即可。
2. 迁移策略:schema_version 与破坏性升级
迁移函数:src/db/connection.rs → Database::run_migrations()
关键点:
- 创建
schema_meta表维护schema_version - 当前实现存在一次性“破坏性升级”:
- 当
current_version < 2时会DROP TABLE ...(会清空历史数据) - 然后重建
assets/snapshots/...等表
- 当
这在“学习/快速迭代阶段”很合理(你能快速重塑数据结构),
但当你开始把它当成长期使用的个人工具时,应该升级为“非破坏性迁移”:
- 新增列/表:
ALTER TABLE ADD COLUMN ... - 数据回填:在迁移里做一次性
UPDATE - 保留历史数据:避免 drop
3. 表结构与字段编码(理解为什么都存 TEXT)
3.1 为什么大量字段使用 TEXT
SQLite 本身类型系统比较宽松,而 Rust 的类型系统很严格。
为了减少“数据库类型与 Rust 类型对齐”的复杂度,本项目选择:
Uuid:存 TEXT(uuid.to_string())Decimal:存 TEXT(decimal.to_string(),避免浮点误差)DateTime<Utc>:存 RFC3339 TEXT(to_rfc3339())NaiveDate:存YYYY-MM-DDTEXT(to_string())- enum:存稳定编码 TEXT(
Category::as_str()/AssetScope::as_str()/VehicleType::as_str())
这让持久化变成“稳定字符串协议”,而解析逻辑集中在 Repository。
3.2 核心表(概念图)
(以 connection.rs 的建表 SQL 为准)
assets:资产条目snapshots:盘点快照(主表)snapshot_items:盘点快照明细allocation_plans:配置方案(主表)allocations:配置项(方案明细)schema_meta:schema 版本
4. Repository 模式:把 SQL 与类型转换封装起来
本项目的 Repository 位于:src/db/*_repo.rs
共同特点:
- 通过
get_database().with_conn(|conn| { ... })获取连接并执行闭包 - SQL 一律使用参数化绑定(
?1 ?2 ...),避免 SQL 注入与字符串拼接错误 - 查询时使用
prepare+query_map,并在row_to_xxx中集中做类型转换
4.1 示例:AssetRepository(读写资产表)
文件:src/db/asset_repo.rs
常见方法:
insert(&Asset)update(&Asset)find_all() -> Vec<Asset>find_by_id(Uuid) -> Option<Asset>
重点看 row_to_asset(row):
- 把 DB 的字符串字段解析成
Uuid/Decimal/DateTime - 把
scope/category/vehicle_type的编码解析回 enum
4.2 示例:SnapshotRepository(快照主表 + 明细表)
文件:src/db/snapshot_repo.rs
值得注意的点:
find_all()先查快照主表,再按snapshot_id查明细- 明细查询用内部函数
find_items_internal(conn, snapshot_id),复用同一连接
这种写法的优点是实现简单,但数据量很大时会产生 N+1 查询问题。
学习期先接受它,后续可用 join/批量查询优化。
4.3 示例:PlanRepository(方案与配置项)
文件:src/db/plan_repo.rs
值得注意的点:
set_active(plan_id)先把所有方案置为非激活,再激活目标方案(保证“只有一个激活”)- 方案更新时先删旧 allocations 再插入新 allocations(简单可靠)
5. 事务(Transaction):让“多步写入”变成原子操作
一个非常典型的改进点:SnapshotRepository::create(snapshot)
它会:
- 插入
snapshots - 循环插入多条
snapshot_items
如果中途失败,就会出现“主表写进去了,明细只写了一部分”的不一致。
更稳健的方式是使用事务:
- BEGIN
- 全部 insert 成功 → COMMIT
- 任一步失败 → ROLLBACK
rusqlite 支持 conn.transaction()(你可以把这作为本章的进阶练习)。
6. 本章练习(从小到大)
练习 A:用一个临时 DB 跑通 CRUD
- 用
ASSET_LIGHT_DB_PATH=/tmp/asset-light-dev.db启动应用 - 新增资产 → 关闭 → 再启动 → 确认数据仍在
- 删除资产 → 再启动 → 确认删除生效
练习 B:给 SnapshotRepository::create 加事务(进阶)
目标:让创建快照“要么全成功,要么全失败”,避免半写入。
练习 C:写一个最小集成测试(进阶)
思路:
- 测试里设置
ASSET_LIGHT_DB_PATH指向临时文件 - 调用
db::init_database() - 调用
AssetRepository::insert/find_all断言结果
你会同时练到:
- 环境变量注入
- DB 初始化
- Repository 行为验证
08 Dioxus 入门:组件、props、Signal、路由与数据流
本章目标
- 你能读懂 Dioxus 的组件写法(
#[component]+rsx!)。 - 你能理解本项目的路由结构与布局组件。
- 你能掌握
Signal的基本用法:全局状态(Context)与页面局部状态(use_signal)。 - 你能在
asset-light里新增一个简单页面/组件并接入导航。
1. Dioxus 的核心概念(用 React 类比)
如果你熟悉 React,可以这样对照:
- 组件函数:React function component ⇔
#[component] fn Foo() -> Element - JSX:JSX ⇔
rsx! { ... } - Props:props ⇔
#[derive(Props)]的 struct 或组件参数 - State:useState ⇔
use_signal - Effect:useEffect ⇔
use_effect - Router:react-router ⇔
dioxus_router的Routable+Router
2. 项目路由与布局
2.1 根组件
文件:src/app.rs
关键点:
provide_app_state()注入全局状态(Context)Router::<Route> {}渲染路由
2.2 路由枚举
文件:src/router.rs
你会看到:
#[derive(Routable)] pub enum Route { ... }#[layout(Layout)]:用布局组件包裹多个页面
这相当于:所有页面都嵌在同一个 Layout(含 Sidebar)中。
2.3 Sidebar 导航
文件:src/components/layout/sidebar.rs
关键点:
Link { to: Route::HomePage {}, ... }实现导航- 用
use_route::<Route>()获取当前路由 - 用
std::mem::discriminant做“当前页面高亮”(按 enum variant 判断)
3. 状态管理:全局 Signal 与局部 Signal
3.1 全局状态 AppState
文件:src/state/app_state.rs
provide_app_state():提供Signal<AppState>use_app_state():在任意组件中获取它
你会在页面里看到典型写法:
let mut state = use_app_state();let assets = state.read().assets.clone();state.write().assets = new_assets;
关键学习点(和所有权/借用相关):
.read()/.write()持有内部借用句柄,尽量缩短作用域- UI 渲染往往会
clone()数据用于展示(学习期可以接受)
3.2 页面局部状态:use_signal
典型场景:模态框开关、编辑态、选择态。
例如 src/pages/assets.rs 里:
show_form: Signal<bool>控制表单 modalediting_asset: Signal<Option<Asset>>当前编辑的资产inventory_mode: Signal<bool>盘点模式开关
你可以把它类比为 React 的:
const [showForm, setShowForm] = useState(false)
4. 副作用:加载数据与刷新
4.1 use_effect:在渲染之外做 IO
例子:src/pages/history.rs / src/pages/analysis.rs 使用 use_effect 在初始渲染时加载 DB 数据。
这类逻辑建议尽量放在 use_effect 里,避免“在 render 路径里写 state”导致的重复执行或难排查问题。
4.2 本项目里的一种“loaded flag”模式
你会在 src/pages/home.rs 看到:
- 用
loaded: Signal<bool>保护“只执行一次”的加载逻辑
这在学习期能跑通,但从工程最佳实践看,你可以把它作为练习:
把 HomePage 的初始加载重构为
use_effect,并理解它与 loaded-flag 的差别。
5. 事件处理与回调(EventHandler)
在 Dioxus 里,事件回调通常是 closure:
on_click: move |_| { ... }
而组件间传递回调,常用 EventHandler<T>(你会在多处组件 props 中见到)。
这和 React 传 onSave={(x) => ...} 很像,但 Rust 的 closure 需要更明确的所有权移动(move)。
6. 本章练习(强烈推荐做)
练习 A:新增一个“设置页”占位并接入导航
目标:走一遍“新增页面 → 路由 → Sidebar”的完整流程。
建议步骤:
- 新建
src/pages/settings.rs,写一个最小页面组件(只显示标题即可) - 在
src/pages/mod.rs里导出它 - 在
src/router.rs的Routeenum 增加一个SettingsPage {}路由 - 在
src/components/layout/sidebar.rs增加一个 NavItem 链接
练习 B:在页面顶部渲染一个全局错误提示条
目标:把上一章的“错误处理”落到 UI 体验中。
思路:
AppState有error: Option<String>- 在 Layout 或每个页面顶部,如果有 error 就渲染一个红色 banner,并提供关闭按钮(清空 error)
练习 C(进阶):把 HomePage 的加载逻辑改成 use_effect
目标:理解“副作用与渲染”的边界,避免重复加载与不可控状态写入。
09 练习清单与进阶路线(把 Rust 用熟)
本章目标
- 给你一条“可持续推进”的练习路线:每次 30-90 分钟,小步可验证。
- 让你在
asset-light这个真实项目里,逐步从“能改”到“改得稳、改得优雅”。
1. 你已经具备的能力(完成前 8 章后)
如果你已经按顺序读完并至少做过每章 1 个练习,你应该能:
- 看懂模块树,能新增模块/文件并正确挂载
- 用所有权/借用解决常见编译错误
- 读懂并扩展
struct/enum/derive/Option/Result - 能在 rusqlite 里新增查询/更新逻辑
- 能在 Dioxus 里新增页面/组件并接入路由与导航
2. 练习路线(从 P0 到 P2)
P0(1-2 天):把“工程节奏”练熟
目标:形成稳定开发闭环(fmt → check → run)。
- 练习 1:新增 Settings 页面并接入 Sidebar(08 章练习 A)
- 练习 2:给
VehicleType实现Display(05 章练习 A) - 练习 3:把一个
unwrap_or_default()改成显式错误(06 章练习 A)
验收标准:
- 每次改动后能
cargo check通过 - 能解释每个改动在模块树中的位置
P1(3-5 天):把“数据一致性与错误反馈”做扎实
目标:让 app 更像产品,而不是 demo。
- 练习 4:给
SnapshotRepository::create增加事务(07 章练习 B) - 练习 5:把错误展示到 UI(08 章练习 B)
- 练习 6:给
Asset增加validate()并在表单保存前校验(05 章练习 B + 06 章)
验收标准:
- 创建快照不会出现半写入
- 用户能在 UI 看到明确错误提示(而不是只看控制台)
- 表单能拦截明显不合法输入(例如 sub_asset_class 与 category 不匹配)
P2(1-2 周):工程化升级(选做,但非常适合学习)
目标:把项目从“能用”升级为“可维护、可演进”。
- 练习 7:引入
AppError(thiserror),逐步替换Result<T, String>(06 章练习 B) - 练习 8:补一套最小集成测试(DB + Repository)(07 章练习 C)
- 练习 9:把 HomePage 的 loaded-flag 加载方式改成
use_effect(08 章练习 C) - 练习 10:为导出/备份准备接口(例如导出 JSON/CSV)(产品向扩展)
验收标准:
- DB 层错误类型结构化,可定位、可分支处理
- 有基本测试护航,重构不慌
- 页面加载逻辑不会出现重复执行/难排查副作用
3. 你下一步应该读什么(学习素材建议)
按你在项目里遇到的困难选择阅读方向:
- 如果你经常卡在 borrow:继续加强所有权/借用(04),同时练习缩小借用作用域
- 如果你经常卡在类型转换:加强建模(05)与错误处理(06),让解析失败显式化
- 如果你想提升工程质量:把
Result<T, String>升级为AppError,并补测试 - 如果你想提升 UI 组织:把样式与组件拆分更清晰,减少页面文件过长
4. 一个推荐的“每周节奏”(适合持续学习)
- 周一/周二:做 1 个 P0 练习 + 复盘编译器报错(把报错记成笔记)
- 周三/周四:做 1 个 P1 练习(事务/校验/错误 UI)
- 周末:做 1 个 P2 练习(错误类型/测试/重构)
坚持 2-4 周,你会非常明显地从“写得慢”变成“写得稳且有把握”。
asset-light 项目技术文档
适用场景
- 你要维护/扩展
asset-light(新增页面、功能、数据结构、优化性能等)。 - 你要理解当前实现(模块划分、数据流、数据库 schema、UI 状态管理)。
- 你要把项目当作 Rust 学习素材,但希望先有“工程地图”。
文档目录
- 项目总览(从代码看架构):
project-overview.md - 数据库与迁移(SQLite / schema_version / Repository):
database.md - 状态与数据流(页面加载策略、全局状态、刷新边界):
state-and-data-flow.md - UI 与路由(Dioxus 组件组织、路由结构、页面职责):
ui-and-routing.md - 编码约定(模块边界、命名、错误处理、可测试性):
coding-conventions.md - 常见问题排查(构建、数据库、数据重置、调试):
troubleshooting.md
与规划/设计文档的关系
docs/ 下已有 PRD、UI 设计、架构设计等文档(主要回答“为什么做 / 做什么”)。
本目录的技术文档更偏向“怎么做 / 现在怎么做 / 怎么改更稳”。
项目总览:从代码看 asset-light
技术栈与依赖(以当前代码为准)
- UI:Dioxus Desktop(Rust 声明式 UI)
- 本地数据库:SQLite(
rusqlite,嵌入式,无需服务端) - 序列化:
serde - 时间:
chrono - ID:
uuid - 金额/比例:
rust_decimal(避免浮点误差)
依赖定义见:Cargo.toml
运行入口
- 应用入口:
src/main.rs- 初始化数据库:
db::init_database() - 启动 UI:
dioxus::launch(app::App)
- 初始化数据库:
- 根组件:
src/app.rs- 提供全局状态:
provide_app_state() - 路由渲染:
Router::<Route> {}
- 提供全局状态:
目录结构(核心)
src/main.rs:进程入口,初始化依赖(数据库)后启动 UIsrc/app.rs:根组件,注入全局状态并挂载路由src/router.rs:路由枚举(页面入口 + layout 包裹)src/error.rs:统一错误类型AppError与AppResult<T>src/pages/*:页面(Home / Assets / History / Plans / Analysis)src/components/*:页面内部 UI 组件(按业务域拆分)src/models/*:领域模型(资产分类体系、资产/快照/方案等)src/db/*:数据库访问层(连接、迁移、Repository,返回AppResult<T>)src/state/*:全局状态(Dioxus Signal Context)src/services/*:业务计算/服务(例如分析计算)src/utils/*:格式化等工具函数
分层与职责(建议的理解方式)
- UI 层(components/pages/router/app)
负责渲染与交互,不直接拼 SQL;从 Repository 拉取数据或触发写入;在需要时更新AppState。 - 数据访问层(db/ + repositories)*
负责 SQL、类型转换与持久化规则(例如字符串到Decimal/Uuid/chrono的解析)。 - 领域模型(models/*)
负责“数据结构 + 稳定编码”,例如资产大类Category、口径AssetScope、工具类型VehicleType、子资产类别编码等。 - 业务服务层(services/*,可选)
负责跨模块的计算逻辑,避免把复杂计算堆进 UI。
数据库位置与环境变量
数据库文件路径由 dirs::data_local_dir() 决定(macOS 通常在用户目录下的 Application Support)。
项目支持通过环境变量指定 DB 路径(便于测试/调试):
ASSET_LIGHT_DB_PATH:指向一个 sqlite 文件路径
对应实现见:src/db/connection.rs 的 Database::db_path()
迁移策略(重要)
当前迁移逻辑内置于 src/db/connection.rs 的 Database::run_migrations():
会维护 schema_meta 表中的 schema_version,并在版本升级时进行表结构处理。
注意:当 schema_version < 2 时,迁移会执行 DROP TABLE(会清空历史数据)。
这适用于早期迭代阶段“允许破坏性升级”的策略,若要进入长期使用阶段,需要改为真正的“非破坏性迁移”。
扩展一个新功能的建议切入点
以“新增一个页面/功能”为例,推荐按顺序改动:
- models:先定义稳定的数据结构与编码(必要时为 DB 做准备)
- db:增加/扩展 Repository 方法(读写与类型转换)
- pages/components:增加 UI 与交互
- state/services:补全全局状态同步或业务计算(需要时)
数据库与迁移(SQLite / schema_version / Repository)
1) 快速定位
- 初始化入口:
src/main.rs→db::init_database() - DB 单例:
src/db/mod.rs(OnceLock<Database>+get_database()) - 连接与迁移:
src/db/connection.rs(Database::new/run_migrations/ensure_default_plan) - Repository:
src/db/asset_repo.rs、src/db/snapshot_repo.rs、src/db/plan_repo.rs
2) DB 文件路径与环境变量
路径规则见:src/db/connection.rs → Database::db_path()
- 默认:
dirs::data_local_dir()下的asset-light/data.db - 可覆盖:环境变量
ASSET_LIGHT_DB_PATH=/path/to/data.db
建议:
- 调试/测试优先用
ASSET_LIGHT_DB_PATH指向临时 DB,避免污染真实数据。
3) 迁移策略(重要:当前包含破坏性升级)
迁移入口:Database::run_migrations()(src/db/connection.rs)
实现要点:
schema_meta表维护schema_version- 当
schema_version < 2时,会执行DROP TABLE ...重建表结构(会清空历史数据)
适用场景:
- 学习期/快速迭代期:允许破坏性升级以快速调整数据模型
不适用场景:
- 长期使用:需要演进为“非破坏性迁移”(新增列/表、回填数据、保留历史记录)
4) 表结构概览(以 connection.rs 为准)
4.1 assets
资产条目。字段类型以 TEXT 为主:
id:UUID stringscope:INVESTABLE/NON_INVESTABLEcategory:CASH/FI/EQ/...sub_asset_class:稳定编码(如EQ_BROAD)vehicle_type:ETF/MUTUAL_FUND/...current_value:Decimal stringcreated_at/updated_at:RFC3339
4.2 snapshots / snapshot_items
快照主表 + 明细表:
- 主表记录盘点时间与总额
- 明细表冗余存储资产当时的分类/口径/工具类型与价值(保证历史可审计)
4.3 allocation_plans / allocations
方案主表 + 配置项表:
set_active()会强制保证“仅一个激活方案”。
5) 编码与类型转换(Rust ⇄ SQLite)
本项目采用"稳定字符串协议"的策略:
Uuid⇄ TEXTDecimal⇄ TEXT(避免浮点误差)DateTime<Utc>⇄ RFC3339 TEXTNaiveDate⇄YYYY-MM-DDTEXT- enum ⇄ 稳定编码 TEXT(
as_str/from_str)
转换逻辑集中在 Repository 的 row_to_xxx 与模型的 as_str/from_str。
已完成改进:Repository 层现已使用 AppResult<T>(定义在 src/error.rs),DB 错误自动转换为 AppError::Database。
待改进:row_to_xxx 内部仍使用 unwrap_or_default() 静默吞解析失败,建议升级为 AppError::Parse 传播(详见 docs/rust-guide/06-error-handling.md)。
6) 原子性与事务(建议改进点)
SnapshotRepository::create() 会写入 1 条主表 + N 条明细。
建议:
- 使用 rusqlite transaction,保证要么全部成功,要么全部失败
7) 数据重置/备份建议
学习期最简单的“重置数据”方式:
- 换一个 DB 路径(
ASSET_LIGHT_DB_PATH指向新文件)
长期使用期建议:
- 增加导出(JSON/CSV)与备份策略
- 将破坏性迁移替换为非破坏性迁移
状态与数据流(页面加载策略、全局状态、刷新边界)
1) 快速定位
- 全局状态定义:
src/state/app_state.rs - 状态注入:
src/app.rs(provide_app_state()) - 页面数据加载:
src/pages/* - 数据访问:
src/db/*_repo.rs
2) AppState 的职责与边界
AppState(src/state/app_state.rs)包含:
assets: Vec<Asset>snapshots: Vec<Snapshot>plans: Vec<AllocationPlan>active_plan: Option<AllocationPlan>loading: boolerror: Option<String>is_inventory_mode: bool(当前更多是 UI 语义,部分页面也有局部 state)
推荐理解方式:
- 数据库是事实来源(source of truth)
- AppState 是 UI 缓存(cache)
- 读 DB 后写入 state,页面渲染从 state 读取
- 写 DB 成功后刷新 state(避免脏 UI)
3) 目前的加载策略(以代码为准)
不同页面采用了不同的“初始加载”风格:
HistoryPage/AnalysisPage:使用use_effect在初始渲染时加载 DB 数据HomePage:使用loadedsignal 做“一次性执行”保护,并在渲染路径中读取 DB(学习期能跑通,但工程上更推荐 use_effect)AssetsPage:更多依赖AppState.assets,并在保存/删除后触发 refreshPlansPage:激活/保存/删除后重新拉取plans并写入 state
这说明项目仍处于“可快速迭代”的阶段,统一数据加载层(Service/Store)仍有空间。
4) 推荐的数据流模式(适合逐步演进)
4.1 最小可控模式(现阶段推荐)
- 页面初始化:从 Repository 拉数据 → 写入 AppState
- 发生写操作(保存/删除/激活):写 DB 成功 → 重新查询 → 写入 AppState
优点:
- 简单可靠,不需要复杂缓存一致性策略
缺点:
- 重复查询较多(但对个人资产规模通常足够)
4.2 进阶模式(后续可做)
- 引入
services/*作为业务入口:- 页面只调用 service
- service 负责 DB + 校验 + 错误类型 + 刷新 state
这会显著减少页面层的重复逻辑。
5) 与所有权/借用相关的注意点(Dioxus Signal)
Signal<AppState> 的 .read()/.write() 会持有内部借用句柄。
建议:
- 缩小借用作用域:读完就 drop,再写
- 必要时 clone:UI 渲染需要拥有数据(尤其跨作用域/闭包)时,用 clone 换取简单性
- 避免在渲染路径写 state:优先 use_effect
6) 典型业务流(从入口到落库)
6.1 新增资产
- UI:
AssetsPage→AssetForm(src/pages/assets.rs/src/components/asset/asset_form.rs) - 写库:
AssetRepository::insert(&asset) - 刷新:
AssetRepository::find_all()→state.write().assets = ...
6.2 发起盘点(生成快照)
- UI:
InventoryMode(src/components/snapshot/inventory_mode.rs) - 写库:
SnapshotRepository::create(&snapshot)+ 更新资产市值(若实现) - 刷新:重新读取 assets/snapshots 写入 state
6.3 切换激活方案
- UI:
PlansPage触发 - 写库:
PlanRepository::set_active(plan_id) - 刷新:
PlanRepository::find_all()→ 更新state.plans(并推导active_plan)
7) 建议的可观测性(学习期非常有帮助)
- 把写库失败写入
state.error并在 Layout 顶部展示(而不是只打印) - 对重要操作(创建快照、激活方案)记录一条可读日志(先
println!,后续可引入日志库)
UI 与路由(Dioxus 组件组织、路由结构、页面职责)
1) 快速定位
- 根组件:
src/app.rs - 路由定义:
src/router.rs - 布局组件:
src/components/layout/mod.rs - (备用)Sidebar 组件:
src/components/layout/sidebar.rs - 页面:
src/pages/* - 业务域组件:
src/components/{asset,snapshot,plan,analysis,dashboard,common}/*
2) 路由结构(以代码为准)
路由枚举:src/router.rs 的 Route
特点:
- 使用
#[layout(Layout)]包裹多个页面 - 每个页面是一个 enum variant(
HomePage {}/AssetsPage {}等)
这意味着:
- Layout 会始终渲染(侧边栏/容器/背景等属于 Layout)
- 页面内容通过
Outlet::<Route> {}注入
3) Layout 与 Sidebar:当前实现的实际情况
目前存在两套“侧边栏”相关实现:
-
Layout 内联导航(当前实际使用)
文件:src/components/layout/mod.rs
Layout 自己写了一个nav { Link { ... } }的简化版本导航。 -
Sidebar 组件(目前未接线/未使用)
文件:src/components/layout/sidebar.rs
具备更完整的 UI 与 active 高亮逻辑(使用use_route+discriminant)。
建议(未来可做的重构):
- 让
Layout直接使用Sidebar {}组件,避免重复实现导航与样式分叉。
4) 页面职责(建议的理解方式)
4.1 Home(首页)
文件:src/pages/home.rs
职责:
- 总览统计(总资产、资产数、当前方案)
- 配置对比与偏离提示(基于可再平衡口径)
- 投资建议(增配/减配)
4.2 Assets(资产管理)
文件:src/pages/assets.rs
职责:
- 资产列表(按类别分组)
- 新增/编辑/删除资产(modal + confirm)
- 发起盘点(进入 inventory mode)
4.3 History(盘点历史)
文件:src/pages/history.rs
职责:
- 展示快照时间线
- 查看快照详情(detail modal)
4.4 Plans(配置方案)
文件:src/pages/plans.rs
职责:
- 方案列表
- 创建/编辑/删除
- 切换激活方案(保证仅一个激活)
4.5 Analysis(收益分析)
文件:src/pages/analysis.rs
职责:
- 周期筛选快照
- 计算收益、归因与趋势(基于快照数据)
- 空状态处理(不足两次快照时提示)
5) 组件组织方式
src/components/ 按业务域拆分:
common/:按钮、输入框、modal、confirm 等通用 UIasset/:资产表单、列表、条目、分组等snapshot/:盘点模式、时间线、快照卡片、详情等plan/:方案列表、卡片、编辑器等analysis/:周期选择、趋势图、归因表等
建议:
- 页面只负责拼装与数据流协调
- 组件尽量纯粹(props → UI),减少直接访问 DB/全局状态
6) 常见 UI 改动的推荐切入点
- 新增一个“业务域组件”:放在对应 domain 目录,并在
mod.rs导出 - 新增页面:
src/pages/xxx.rs+src/pages/mod.rs导出 +src/router.rs增加 Route + Layout/Sidebar 增加 Link - 通用交互(toast/banner/loading):优先做在 Layout 或 common 组件中统一管理
编码约定(模块边界、命名、错误处理、可测试性)
1) 目标
本约定的目标是让项目在持续迭代中保持:
- 可读(容易理解)
- 可改(容易扩展)
- 可测(容易验证)
- 可诊断(出问题容易定位)
2) 模块边界(建议的分层)
- models/:领域模型 + 稳定编码/解析/展示逻辑(不要在这里写 SQL)
- db/:SQL + 类型转换 + Repository(不要在这里写 UI)
- services/:跨模块业务计算(偏离/收益等),避免页面堆业务逻辑
- state/:全局状态(Signal Context),尽量保持“数据结构 + 少量纯函数”
- pages/:页面级装配与数据流协调(加载/刷新/路由入口)
- components/:可复用 UI 组件(尽量纯 props 驱动)
- utils/:与业务无关的工具函数(格式化、通用 helper)
- error.rs:统一错误类型
AppError与AppResult<T>
3) 命名与可读性(贴近《代码整洁之道》)
- 函数名优先用动词短语:
find_all、find_by_id、set_active
- 布尔变量表达意图:
is_active、inventory_mode
- 避免缩写与魔法字符串:
- 类别/口径/工具类型使用 enum +
as_str/from_str
- 类别/口径/工具类型使用 enum +
4) 错误处理(当前设计与改进方向)
项目已在 src/error.rs 定义统一错误类型:
AppError(使用thiserror)type AppResult<T> = Result<T, AppError>
Repository 层(src/db/*_repo.rs)现已返回 AppResult<T>。
原则:
- 不要静默吞错(例如
row_to_xxx中的unwrap_or_default()把脏数据变默认值,仍待改进) - 用户输入/DB/IO 必须返回 Result,不要 panic
- 测试里可以 unwrap,但要保证失败信息可读(
expect更好)
5) 数据建模与持久化
原则:
- DB 里存稳定编码(TEXT)
- 模型负责提供编码 ↔ enum 的转换函数
- 重要不变量写进模型(例如
Asset::validate()/ 方案占比合计 100%)
6) Dioxus 组件与状态管理
建议:
- 页面负责“加载/刷新”,组件负责“渲染/交互”
- 尽量不要在渲染路径写 state(优先
use_effect) - 缩小
.read()/.write()的借用作用域,避免借用冲突
7) 测试策略(学习期可从最小集开始)
建议从两类测试开始:
- 模型纯函数测试:
AssetSubCategory::from_code、Category::from_str、收益/偏离计算 - DB 集成测试:设置
ASSET_LIGHT_DB_PATH指向临时文件,跑init_database,验证 CRUD
目标不是追求覆盖率,而是建立“重构有安全网”的能力。
常见问题排查(构建、数据库、数据重置、调试)
1) 构建/依赖下载失败
现象:
cargo build/cargo run下载 crates 失败- 报错类似 “couldn't resolve host / failed to download”
原因:
- 网络环境无法访问
crates.io或 DNS/代理问题
解决建议:
- 确认网络可访问 crates(必要时配置代理)
- CI 环境确保网络稳定
- 先用
cargo check快速定位类型问题(避免每次都 full build)
2) 应用能启动,但数据“看起来丢了”
常见原因:
- 你切换了 DB 路径(设置了
ASSET_LIGHT_DB_PATH) - 迁移发生了破坏性升级,表被 drop(学习期允许)
排查步骤:
- 检查是否设置了
ASSET_LIGHT_DB_PATH - 查看
src/db/connection.rs的run_migrations()是否 drop 过表(schema_version < 2)
3) 想要重置数据(不影响真实 DB)
推荐方式:换一个临时 DB 路径启动
ASSET_LIGHT_DB_PATH=/tmp/asset-light-dev.db cargo run
4) UI 没刷新/数据没更新
排查思路:
- 写库成功后是否刷新 state?
- 例如保存资产后是否调用
AssetRepository::find_all()并写入state.assets
- 例如保存资产后是否调用
- 是否存在“读写借用作用域过长”导致逻辑没执行到?
- 尽量缩小
.read()/.write()的使用范围
- 尽量缩小
5) 快照创建中途失败,数据不一致
现状:
SnapshotRepository::create()是“多步写入”(主表 + N 条明细)- 目前未显式使用事务
建议:
- 用 rusqlite transaction 改为原子操作(见
docs/rust-guide/07-sqlite-and-rusqlite.md)
6) 如何快速定位某个功能在哪实现
建议从“页面入口”反查:
- 路由:
src/router.rs - 页面:
src/pages/*.rs - 页面组合组件:
src/components/* - 数据访问:
src/db/*_repo.rs - 领域模型:
src/models/* - 计算逻辑:
src/services/*
7) 调试建议(学习期最实用)
- 先用
println!/eprintln!快速定位数据流(之后再引入日志库) - 在关键写入点打印:
- “写库前数据是什么”
- “写库结果是什么”
- “刷新 state 后数据长度/关键字段是什么”
Product Brief: asset-light
Date: 2025-12-20 Author: BMad Context: 个人项目 + 技术学习
Executive Summary
asset-light 是一款基于 Dioxus (Rust) 技术栈的个人资产管理桌面应用,旨在解决个人投资者在资产盘点、变动追踪和配置优化过程中遇到的效率和洞察力问题。
核心用户痛点是:现有的表格工具(如飞书表格/Excel)操作繁琐,无法追踪资产历史变化,也无法分析各项资产的真实收益贡献。用户需要一个能够引导资产配置向目标方案靠近、实时监控配置偏离度、并提供收益归因分析的专业工具。
该项目同时承载技术学习目标——通过实战项目深入掌握 Dioxus/Rust 桌面应用开发生态。
Core Vision
Problem Statement
当前状态:
个人投资者使用飞书表格/Excel 管理资产时面临以下挑战:
- 操作效率低:每次资产盘点需要手动更新多个单元格,缺乏结构化的数据录入流程
- 历史追踪缺失:资产的历史变化(买入/卖出/调仓/分红等)没有被系统记录,无法回溯分析
- 收益归因不清:无法区分哪些资产在产生收益,哪些在拖后腿,整体收益来源不透明
- 配置管理困难:
- 无法直观对比当前配置与目标配置的偏离程度
- 缺乏再平衡提醒和建议
- 难以执行"高抛低吸"的纪律性投资策略
核心矛盾:
用户拥有成熟的资产配置理念(如核心+卫星策略),但缺乏一个能够将理念转化为可执行、可追踪、可分析的系统化工具。
Why Existing Solutions Fall Short
| 现有方案 | 不足之处 |
|---|---|
| 飞书表格/Excel | 操作繁琐、无历史追踪、无分析能力、需要大量手动计算 |
| 记账类 APP | 侧重消费记录,资产管理功能弱,无法自定义配置方案 |
| 券商/银行 APP | 账户分散、无法聚合全部资产、无配置偏离分析 |
| 专业资管软件 | 面向机构,过于复杂,对个人用户不友好 |
Proposed Solution
asset-light 将提供:
- 结构化资产盘点:按资产类别(现金类/稳健类/进阶类)快速录入和更新资产
- 全量历史追踪:记录每一笔资产变动,支持时间线回溯
- 收益归因分析:清晰展示各资产/类别的收益贡献
- 配置偏离监控:可视化对比当前配置与目标配置,自动计算偏离度
- 再平衡建议:基于偏离阈值提供调仓建议,帮助用户维持投资纪律
Key Differentiators
- 个人视角:专为个人投资者设计,聚焦"一个人的资产全景"
- 配置驱动:以目标配置方案为核心,所有功能围绕"向目标靠近"展开
- 本地优先:Rust 桌面应用,数据本地存储,隐私安全
- 轻量专注:不做记账、不做社交,只做资产管理这一件事
Target Users
Primary Users
用户画像:
- 个人投资者,有一定的理财知识和资产配置理念
- 当前使用表格类工具管理资产,但感到效率低下
- 资产分布在多个平台(银行、券商、支付宝、理财平台等)
- 希望实现资产的聚合视图和系统化管理
- 有纪律性投资的意愿,需要工具辅助执行
行为特征:
- 每月/每季度进行资产盘点
- 关注资产增值而非日常消费
- 有明确的资产配置目标(如核心+卫星策略)
- 偏好桌面应用的专注和效率
核心诉求:
- 盘点资产时快速、准确、省心
- 随时了解当前配置与目标的差距
- 清晰看到哪些资产在赚钱
MVP Scope
Core Features(MVP 必备)
1. 资产盘点模块
| 功能 | 说明 |
|---|---|
| 资产条目管理 | 新增/编辑/删除资产条目 |
| 资产分类 | 按类别组织:现金类、稳健类、进阶类(支持自定义子类别) |
| 快照式盘点 | 手动输入当前市值,系统自动记录盘点时间戳 |
| 盘点历史 | 保存每次盘点的完整快照,支持历史查看 |
2. 配置方案模块
| 功能 | 说明 |
|---|---|
| 多方案支持 | 用户可自定义多套目标配置方案 |
| 层级配置 | 支持核心/卫星两层结构,每层可细分子类别和占比 |
| 方案切换 | 可切换当前激活的目标方案 |
3. 资产视图模块
| 功能 | 说明 |
|---|---|
| 总览视图 | 展示资产总额、各类别占比 |
| 配置对比 | 当前配置 vs 目标配置的可视化对比 |
| 偏离提示 | 显示各类别的偏离百分比(如"稳健类偏离 +5%") |
4. 收益分析模块
| 功能 | 说明 |
|---|---|
| 周期收益 | 计算季度/年度收益率 |
| 分类收益 | 各资产类别的收益贡献对比 |
| 历史趋势 | 资产总额的历史变化曲线 |
Out of Scope for MVP(暂不实现)
| 功能 | 延后原因 |
|---|---|
| 自动获取净值 | 需要对接外部 API,增加复杂度 |
| 分红/调仓记录 | MVP 采用快照模式,简化数据模型 |
| 具体调仓建议 | "卖出 XX 基金 X 元" 需要更复杂的计算逻辑 |
| 月度收益分析 | MVP 聚焦季度/年度周期 |
| 多端同步 | 本地优先,暂不考虑云同步 |
| 导入/导出 | 可作为后续迭代功能 |
MVP Success Criteria
- 用户能在 3 分钟内 完成一次资产盘点(10 个资产条目以内)
- 用户能清晰看到 当前配置与目标配置的偏离情况
- 用户能查看 季度/年度收益率 及各类别收益贡献
- 用户能 自定义至少 2 套 配置方案并切换
- 应用启动速度 < 2 秒,操作响应流畅
Future Vision(后续迭代方向)
Phase 2:精细化追踪
- 支持分红、调仓、申购/赎回等操作记录
- 真实收益率计算(考虑资金流入流出)
Phase 3:智能化
- 自动获取基金净值
- 具体再平衡调仓建议
- 配置健康度评分
Phase 4:生态扩展
- 数据导入/导出(CSV、JSON)
- 投资组合分析报告生成
- 可选的云同步功能
Technical Preferences
技术栈选择
| 层面 | 技术选型 | 选择理由 |
|---|---|---|
| 框架 | Dioxus | Rust 生态的现代 UI 框架,支持桌面应用 |
| 语言 | Rust | 性能、安全性、学习目标 |
| 平台 | Desktop (macOS 优先) | 专注沉浸式操作体验 |
| 数据存储 | 本地文件 (SQLite/JSON) | 隐私优先,离线可用 |
技术学习目标
本项目同时作为 Dioxus/Rust 生态的学习载体,期望掌握:
- Dioxus 组件化开发模式
- Rust 桌面应用的数据持久化方案
- 状态管理与响应式 UI
- Rust 项目工程化实践
Success Metrics
产品成功指标
作为个人项目,成功的定义是双重的:
维度一:实用性(自己用起来比飞书表格更顺手)
| 指标 | 目标 |
|---|---|
| 盘点效率 | 单次盘点时间 < 飞书表格的 50% |
| 使用频率 | 每季度至少进行 1 次完整盘点 |
| 功能覆盖 | 能完全替代飞书表格的资产管理功能 |
| 数据洞察 | 能回答"哪些资产在赚钱"这个问题 |
维度二:技术学习(走通 Dioxus 桌面应用开发流程)
| 指标 | 目标 |
|---|---|
| 项目完整性 | 完成从 0 到 1 的桌面应用开发全流程 |
| 技术掌握 | 熟练使用 Dioxus 组件、状态管理、数据持久化 |
| 代码质量 | 遵循 Rust 最佳实践,代码结构清晰 |
| 可维护性 | 后续迭代时能快速理解和修改代码 |
Asset Classification Reference
目标配置结构(核心+卫星策略)
资产总览
├── 核心资产 (70%-80%) ─────────────────────────────────────
│ │
│ ├── 稳健固收类 (40%-50%)
│ │ ├── 大额存单/国债/国债逆回购 (10%-15%)
│ │ │ └── 安全垫,绝对安全 + 高流动性
│ │ ├── 银行理财-中低风险 (15%-20%)
│ │ │ └── R2/R3 等级,收益稳定
│ │ └── 债券型基金/纯债基金 (15%)
│ │ └── 长期持有,波动小,可考虑"固收+"
│ │
│ └── 长期权益类 (30%)
│ ├── 宽基指数基金定投 (20%)
│ │ └── 沪深300、中证500,平滑风险
│ └── 优质主动管理型基金 (10%)
│ └── 长期业绩优秀,基金经理稳定
│
└── 卫星资产 (20%-30%) ─────────────────────────────────────
│
├── 行业/主题基金 (10%-15%)
│ └── 科技、消费、医药、新能源等 ETF
├── 个股投资 (5%-10%)
│ └── 3-5 支理解深刻的优质公司
└── 另类投资 (5%)
├── 黄金 ETF(避险对冲)
└── REITs(不动产收益)
现有资产分类映射
| 系统分类 | 对应配置层级 | 示例资产 |
|---|---|---|
| 现金类 | 核心 > 稳健固收 | 余额宝、活期存款、定期存单、借出款项 |
| 稳健类 | 核心 > 稳健固收 | 债券基金 |
| 进阶类 | 核心 > 权益类 / 卫星 | 股票基金、混合基金、ETF |
Risks and Assumptions
关键假设
| 假设 | 验证方式 |
|---|---|
| 快照式盘点足以满足 MVP 需求 | 实际使用中验证是否需要更细粒度的记录 |
| 手动输入市值可接受 | 评估是否影响使用意愿和数据准确性 |
| Dioxus 成熟度足够支撑项目 | 开发过程中评估框架稳定性和生态完善度 |
| 季度/年度收益分析周期合理 | 使用中验证是否需要更灵活的时间范围 |
潜在风险
| 风险 | 影响 | 缓解策略 |
|---|---|---|
| Dioxus 学习曲线较陡 | 开发进度延迟 | 参考官方文档和社区资源,必要时简化功能 |
| 数据模型设计不合理 | 后续迭代困难 | 在开发初期做好数据结构设计 |
| 功能蔓延 | MVP 无法完成 | 严格遵守 Out of Scope 边界 |
Appendix
术语定义
| 术语 | 定义 |
|---|---|
| 快照式盘点 | 每次记录资产当前市值的完整状态,不追踪中间变动 |
| 配置偏离度 | 当前资产配置与目标配置的百分比差异 |
| 核心资产 | 追求稳定和安全的资产,占比 70-80% |
| 卫星资产 | 追求更高收益、容忍更高波动的资产,占比 20-30% |
| 再平衡 | 调整资产配置使其回归目标比例的操作 |
This Product Brief captures the vision and requirements for asset-light.
It was created through collaborative discovery and reflects the unique needs of this 个人项目 + 技术学习 project.
Next: PRD workflow will transform this brief into detailed product requirements.
Product Requirements Document (PRD)
asset-light - 个人资产管理桌面应用
版本: 1.0.0 (MVP)
日期: 2025-12-20
状态: Validated
作者: PM Agent
关联文档: Product Brief
1. 概述
1.1 文档目的
本文档详细定义 asset-light MVP 版本的产品需求,将 Product Brief 中的愿景转化为可执行的功能规格,为后续的架构设计和开发实施提供明确指导。
1.2 产品定位
asset-light 是一款基于 Dioxus (Rust) 技术栈的个人资产管理桌面应用,核心价值主张:
让个人投资者能够系统化地管理资产配置,追踪配置偏离度,并获得清晰的收益归因分析。
1.3 目标用户
| 特征 | 描述 |
|---|---|
| 身份 | 个人投资者,具备一定理财知识 |
| 现状 | 使用表格工具管理资产,感到效率低下 |
| 痛点 | 无法追踪历史变化、无法分析收益来源、无法监控配置偏离 |
| 期望 | 快速盘点、偏离监控、收益分析、纪律执行 |
2. 功能需求
2.1 功能模块总览
asset-light MVP
├── F1. 资产盘点模块
├── F2. 配置方案模块
├── F3. 资产视图模块
└── F4. 收益分析模块
2.2 F1: 资产盘点模块
2.2.1 功能描述
提供结构化的资产录入和盘点能力,支持按分类管理资产条目,记录每次盘点的完整快照。
2.2.2 用户故事
| ID | 用户故事 | 优先级 |
|---|---|---|
| US-F1-01 | 作为用户,我希望能够新增一个资产条目(包含名称、类别、当前市值),以便记录我持有的资产 | P0 |
| US-F1-02 | 作为用户,我希望能够编辑已有资产条目的信息,以便修正录入错误或更新资产状态 | P0 |
| US-F1-03 | 作为用户,我希望能够删除不再持有的资产条目,以便保持资产列表的准确性 | P0 |
| US-F1-04 | 作为用户,我希望资产条目能够按类别(现金类/稳健类/进阶类)组织展示,以便快速定位和管理 | P0 |
| US-F1-05 | 作为用户,我希望能够执行一次"盘点"操作,批量更新所有资产的当前市值,并自动记录盘点时间戳 | P0 |
| US-F1-06 | 作为用户,我希望能够查看历史盘点记录列表,以便回顾资产变化轨迹 | P0 |
| US-F1-07 | 作为用户,我希望能够查看某次历史盘点的详细快照,以便了解当时的资产状态 | P1 |
| US-F1-08 | 作为用户,我希望能够自定义资产子类别(如将"进阶类"细分为"宽基指数"和"行业ETF"),以便更精细地管理资产 | P1 |
2.2.3 功能规格
FR-F1-01: 资产条目数据模型
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | UUID | 是 | 资产条目唯一标识 |
| name | String | 是 | 资产名称(如"余额宝"、"沪深300ETF") |
| category | Enum | 是 | 一级分类:Cash / Stable / Advanced |
| sub_category | String | 否 | 用户自定义子类别 |
| current_value | Decimal | 是 | 当前市值(单位:元) |
| notes | String | 否 | 备注信息 |
| created_at | DateTime | 是 | 创建时间 |
| updated_at | DateTime | 是 | 最后更新时间 |
FR-F1-02: 资产类别定义
| 类别 | 英文标识 | 描述 | 预设子类别 |
|---|---|---|---|
| 现金类 | Cash | 高流动性、低风险资产 | 活期存款、货币基金、定期存单、借出款项 |
| 稳健类 | Stable | 固定收益、中低风险资产 | 银行理财、债券基金、纯债基金 |
| 进阶类 | Advanced | 权益类、中高风险资产 | 宽基指数、行业ETF、主动基金、个股 |
FR-F1-03: 盘点快照数据模型
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | UUID | 是 | 盘点快照唯一标识 |
| snapshot_date | Date | 是 | 盘点日期 |
| created_at | DateTime | 是 | 快照创建时间 |
| total_value | Decimal | 是 | 资产总额 |
| items | Array | 是 | 资产条目快照列表 |
FR-F1-04: 盘点快照条目结构
| 字段 | 类型 | 说明 |
|---|---|---|
| asset_id | UUID | 关联的资产条目 ID |
| asset_name | String | 资产名称(快照时冗余存储) |
| category | Enum | 资产类别 |
| sub_category | String | 子类别 |
| value | Decimal | 盘点时的市值 |
2.2.4 业务规则
| 规则ID | 描述 |
|---|---|
| BR-F1-01 | 资产名称不可为空,最大长度 100 字符 |
| BR-F1-02 | 市值必须为非负数,精度为小数点后 2 位 |
| BR-F1-03 | 删除资产条目时,历史快照中的该条目数据保留(快照不可变) |
| BR-F1-04 | 每天最多允许创建 5 次盘点快照(防止误操作) |
| BR-F1-05 | 盘点时至少需要有 1 个资产条目存在 |
2.2.5 界面要求
UI-F1-01: 资产列表页
- 按类别分组展示所有资产条目
- 每个类别显示:类别名称、资产数量、类别总市值
- 每个资产条目显示:名称、子类别(如有)、当前市值
- 支持操作:新增资产、编辑资产、删除资产、发起盘点
- 类别折叠/展开支持
UI-F1-02: 资产编辑表单
- 模态框或侧边栏形式
- 字段:名称(输入框)、类别(下拉选择)、子类别(输入框/下拉)、市值(数字输入)、备注(文本域)
- 保存/取消按钮
- 实时验证反馈
UI-F1-03: 盘点流程
- 盘点按钮触发进入盘点模式
- 盘点模式下,资产列表变为可编辑状态,市值字段可直接修改
- 显示上次盘点市值作为参考
- 完成盘点按钮:保存快照并退出盘点模式
- 取消盘点按钮:放弃本次修改
UI-F1-04: 盘点历史页
- 时间线形式展示历史盘点记录
- 每条记录显示:盘点日期、资产总额、相比上次变化金额/百分比
- 点击可查看该快照的详细资产明细
2.3 F2: 配置方案模块
2.3.1 功能描述
支持用户定义多套目标资产配置方案,采用核心+卫星的层级结构,可切换当前激活的方案用于偏离度计算。
2.3.2 用户故事
| ID | 用户故事 | 优先级 |
|---|---|---|
| US-F2-01 | 作为用户,我希望能够创建一个配置方案,定义各资产类别的目标占比,以便作为资产配置的参照标准 | P0 |
| US-F2-02 | 作为用户,我希望能够编辑已有的配置方案,以便根据投资策略变化调整目标配置 | P0 |
| US-F2-03 | 作为用户,我希望能够删除不再使用的配置方案,以便保持方案列表简洁 | P1 |
| US-F2-04 | 作为用户,我希望能够将某个方案设为"当前激活",用于计算配置偏离度 | P0 |
| US-F2-05 | 作为用户,我希望配置方案能够支持两层结构(核心/卫星),以便实现更精细的配置管理 | P1 |
| US-F2-06 | 作为用户,我希望系统提供一个预设的"平衡型"配置方案作为起点,以便快速开始使用 | P1 |
2.3.3 功能规格
FR-F2-01: 配置方案数据模型
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | UUID | 是 | 方案唯一标识 |
| name | String | 是 | 方案名称(如"保守型"、"进取型") |
| description | String | 否 | 方案描述 |
| is_active | Boolean | 是 | 是否为当前激活方案 |
| allocations | Array | 是 | 配置项列表 |
| created_at | DateTime | 是 | 创建时间 |
| updated_at | DateTime | 是 | 更新时间 |
FR-F2-02: 配置项结构(MVP 简化版)
| 字段 | 类型 | 说明 |
|---|---|---|
| category | Enum | 资产类别:Cash / Stable / Advanced |
| target_percentage | Decimal | 目标占比(0-100) |
| min_percentage | Decimal | 最低占比(可选,用于偏离提示) |
| max_percentage | Decimal | 最高占比(可选,用于偏离提示) |
FR-F2-03: 配置项结构(扩展版 - 支持子类别)
| 字段 | 类型 | 说明 |
|---|---|---|
| category | Enum | 资产类别 |
| layer | Enum | 层级:Core(核心)/ Satellite(卫星) |
| sub_category | String | 子类别(可选) |
| target_percentage | Decimal | 目标占比 |
| tolerance | Decimal | 容忍偏离度(默认 5%) |
2.3.4 业务规则
| 规则ID | 描述 |
|---|---|
| BR-F2-01 | 方案名称不可为空,最大长度 50 字符 |
| BR-F2-02 | 同一方案内,所有配置项的 target_percentage 之和必须等于 100% |
| BR-F2-03 | 系统始终有且仅有一个激活方案 |
| BR-F2-04 | 不能删除当前激活的方案(需先切换激活方案) |
| BR-F2-05 | 系统至少保留一个配置方案 |
2.3.5 界面要求
UI-F2-01: 配置方案列表页
- 展示所有配置方案,当前激活方案高亮显示
- 每个方案显示:名称、描述摘要、激活状态
- 支持操作:新建方案、编辑方案、删除方案、激活方案
UI-F2-02: 配置方案编辑器
- 方案基础信息:名称、描述
- 配置项可视化编辑:
- 使用滑块或输入框调整各类别占比
- 实时显示占比总和(必须 = 100%)
- 饼图预览目标配置
- 保存时自动校验规则
UI-F2-03: 预设方案模板
MVP 提供以下预设模板供用户快速创建:
| 模板名称 | Cash | Stable | Advanced |
|---|---|---|---|
| 保守型 | 30% | 50% | 20% |
| 平衡型 | 20% | 40% | 40% |
| 进取型 | 10% | 20% | 70% |
2.4 F3: 资产视图模块
2.4.1 功能描述
提供资产的全局视图,展示当前资产配置与目标配置的对比,可视化偏离情况,帮助用户快速了解资产健康状态。
2.4.2 用户故事
| ID | 用户故事 | 优先级 |
|---|---|---|
| US-F3-01 | 作为用户,我希望在首页看到资产总额和各类别的分布情况,以便快速了解资产全貌 | P0 |
| US-F3-02 | 作为用户,我希望能够直观对比当前配置与目标配置的差异,以便判断是否需要调整 | P0 |
| US-F3-03 | 作为用户,我希望当某类别偏离目标超过阈值时,系统能够高亮提示,以便及时关注 | P0 |
| US-F3-04 | 作为用户,我希望看到各类别的偏离百分比(如"+5%"、"-3%"),以便量化偏离程度 | P0 |
| US-F3-05 | 作为用户,我希望能够一键查看再平衡所需的资金调整方向,以便规划调仓 | P2 |
2.4.3 功能规格
FR-F3-01: 资产总览数据
| 指标 | 计算方式 |
|---|---|
| 资产总额 | 所有资产条目 current_value 之和 |
| 类别市值 | 该类别下所有资产条目 current_value 之和 |
| 类别占比 | 类别市值 / 资产总额 × 100% |
| 偏离度 | 当前占比 - 目标占比 |
| 偏离状态 | 超出容忍阈值则标记为"偏离" |
FR-F3-02: 偏离度计算逻辑
deviation = current_percentage - target_percentage
if abs(deviation) > tolerance:
status = "deviating" (偏离)
else:
status = "normal" (正常)
if deviation > 0:
direction = "overweight" (超配)
else:
direction = "underweight" (低配)
2.4.4 界面要求
UI-F3-01: 资产总览页(首页)
布局结构:
┌─────────────────────────────────────────────────────────────┐
│ 资产总额 │
│ ¥ 1,234,567.89 上次盘点: 2025-12-15│
├─────────────────────────────────────────────────────────────┤
│ │
│ [饼图: 当前配置] [饼图: 目标配置] │
│ │
├─────────────────────────────────────────────────────────────┤
│ 类别配置对比 │
│ ┌──────────┬──────────┬──────────┬──────────┬───────────┐ │
│ │ 类别 │ 当前占比 │ 目标占比 │ 偏离度 │ 状态 │ │
│ ├──────────┼──────────┼──────────┼──────────┼───────────┤ │
│ │ 现金类 │ 25% │ 20% │ +5% │ ⚠️ 超配 │ │
│ │ 稳健类 │ 38% │ 40% │ -2% │ ✓ 正常 │ │
│ │ 进阶类 │ 37% │ 40% │ -3% │ ✓ 正常 │ │
│ └──────────┴──────────┴──────────┴──────────┴───────────┘ │
└─────────────────────────────────────────────────────────────┘
UI-F3-02: 偏离提示样式
| 偏离状态 | 视觉表现 |
|---|---|
| 正常( | 偏离度 |
| 轻度偏离( | 偏离度 |
| 严重偏离( | 偏离度 |
UI-F3-03: 配置对比可视化
- 双饼图并列展示:左侧当前配置,右侧目标配置
- 同类别使用相同颜色,便于对比
- 鼠标悬停显示具体金额和百分比
2.5 F4: 收益分析模块
2.5.1 功能描述
基于盘点快照数据,计算和展示资产收益情况,支持按时间周期和类别维度分析收益来源。
2.5.2 用户故事
| ID | 用户故事 | 优先级 |
|---|---|---|
| US-F4-01 | 作为用户,我希望看到选定时间段内的资产总收益(金额和百分比),以便了解整体收益情况 | P0 |
| US-F4-02 | 作为用户,我希望能够按季度/年度查看收益,以便对比不同周期的表现 | P0 |
| US-F4-03 | 作为用户,我希望看到各资产类别的收益贡献,以便了解哪些类别在产生收益 | P0 |
| US-F4-04 | 作为用户,我希望看到资产总额的历史变化趋势图,以便直观感受资产增长轨迹 | P1 |
| US-F4-05 | 作为用户,我希望能够选择任意两个盘点时间点进行收益对比,以便灵活分析 | P2 |
2.5.3 功能规格
FR-F4-01: 收益计算逻辑(快照模式简化版)
# 时间段收益计算
period_return = end_snapshot.total_value - start_snapshot.total_value
period_return_rate = period_return / start_snapshot.total_value × 100%
# 类别收益计算
category_return = end_category_value - start_category_value
category_contribution = category_return / period_return × 100% # 收益贡献度
注意: MVP 采用快照模式,不追踪资金流入/流出,因此计算的是"市值变动"而非"真实收益率"。
FR-F4-02: 时间周期定义
| 周期 | 定义 |
|---|---|
| 季度 | 自然季度(Q1: 1-3月, Q2: 4-6月, Q3: 7-9月, Q4: 10-12月) |
| 年度 | 自然年(1月1日 - 12月31日) |
| 自定义 | 用户选择的起止日期 |
FR-F4-03: 收益分析数据结构
| 字段 | 类型 | 说明 |
|---|---|---|
| period | Object | 时间段 {start_date, end_date} |
| start_value | Decimal | 期初总市值 |
| end_value | Decimal | 期末总市值 |
| absolute_return | Decimal | 绝对收益金额 |
| return_rate | Decimal | 收益率(%) |
| category_breakdown | Array | 各类别收益明细 |
FR-F4-04: 类别收益明细结构
| 字段 | 类型 | 说明 |
|---|---|---|
| category | Enum | 资产类别 |
| start_value | Decimal | 期初市值 |
| end_value | Decimal | 期末市值 |
| absolute_return | Decimal | 绝对收益 |
| contribution_rate | Decimal | 收益贡献度(%) |
2.5.4 业务规则
| 规则ID | 描述 |
|---|---|
| BR-F4-01 | 至少需要 2 个盘点快照才能计算收益 |
| BR-F4-02 | 期初市值为 0 时,收益率显示为"N/A" |
| BR-F4-03 | 季度/年度收益使用该周期内最早和最晚的快照计算 |
| BR-F4-04 | 若某周期内无快照数据,该周期收益显示为"暂无数据" |
2.5.5 界面要求
UI-F4-01: 收益分析页
布局结构:
┌─────────────────────────────────────────────────────────────┐
│ 周期选择器: [季度 ▾] [2025年 ▾] [Q4 ▾] [自定义日期] │
├─────────────────────────────────────────────────────────────┤
│ 本期收益概览 │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 收益金额 │ │ 收益率 │ │
│ │ +¥ 12,345.67 │ │ +3.2% │ │
│ │ (期初 → 期末) │ │ │ │
│ └─────────────────┘ └─────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 收益归因分析 │
│ ┌──────────┬──────────┬──────────┬──────────┐ │
│ │ 类别 │ 收益金额 │ 收益率 │ 贡献度 │ │
│ ├──────────┼──────────┼──────────┼──────────┤ │
│ │ 现金类 │ +¥ 1,234 │ +1.5% │ 10% │ │
│ │ 稳健类 │ +¥ 3,456 │ +2.8% │ 28% │ │
│ │ 进阶类 │ +¥ 7,655 │ +5.2% │ 62% │ │
│ └──────────┴──────────┴──────────┴──────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 资产趋势图 │
│ [折线图: 历史盘点时间点的资产总额变化] │
│ │
└─────────────────────────────────────────────────────────────┘
UI-F4-02: 收益归因可视化
- 横向条形图展示各类别的收益贡献
- 正收益绿色,负收益红色
- 支持点击展开查看该类别下各资产的收益明细
UI-F4-03: 资产趋势图
- X 轴:盘点日期时间线
- Y 轴:资产总额
- 数据点标注具体金额
- 支持缩放和时间范围选择
3. 非功能需求
3.1 性能需求
| ID | 需求 | 指标 |
|---|---|---|
| NFR-P-01 | 应用启动时间 | < 2 秒(冷启动) |
| NFR-P-02 | 页面切换响应 | < 200ms |
| NFR-P-03 | 盘点保存时间 | < 500ms(100 个资产条目以内) |
| NFR-P-04 | 数据加载时间 | < 1s(1000 条历史快照) |
3.2 可用性需求
| ID | 需求 | 说明 |
|---|---|---|
| NFR-U-01 | 首次使用引导 | 提供简洁的功能引导,帮助用户快速上手 |
| NFR-U-02 | 键盘快捷键 | 支持常用操作的快捷键(如 Cmd+N 新增资产) |
| NFR-U-03 | 操作反馈 | 所有操作提供明确的成功/失败反馈 |
| NFR-U-04 | 数据校验 | 输入时实时校验,防止无效数据 |
| NFR-U-05 | 确认机制 | 删除等破坏性操作需二次确认 |
3.3 数据需求
| ID | 需求 | 说明 |
|---|---|---|
| NFR-D-01 | 本地存储 | 所有数据存储在本地,不依赖网络 |
| NFR-D-02 | 数据格式 | SQLite 数据库或 JSON 文件 |
| NFR-D-03 | 数据备份 | 支持手动导出数据文件 |
| NFR-D-04 | 数据完整性 | 操作失败时保证数据不被损坏 |
3.4 安全需求
| ID | 需求 | 说明 |
|---|---|---|
| NFR-S-01 | 隐私保护 | 数据仅存储在用户本地设备 |
| NFR-S-02 | 无网络传输 | MVP 版本不进行任何网络通信 |
3.5 兼容性需求
| ID | 需求 | 说明 |
|---|---|---|
| NFR-C-01 | macOS 支持 | 支持 macOS 12.0+ |
| NFR-C-02 | 分辨率适配 | 支持 1280×720 及以上分辨率 |
| NFR-C-03 | 深色模式 | 支持系统深色模式(P1) |
4. 信息架构
4.1 页面结构
asset-light
├── 首页(资产总览)
│ ├── 资产总额卡片
│ ├── 配置对比图表
│ └── 偏离状态列表
│
├── 资产管理
│ ├── 资产列表(按类别分组)
│ ├── 新增/编辑资产表单
│ └── 盘点模式
│
├── 盘点历史
│ ├── 历史时间线
│ └── 快照详情
│
├── 配置方案
│ ├── 方案列表
│ └── 方案编辑器
│
└── 收益分析
├── 周期选择器
├── 收益概览
├── 收益归因表
└── 资产趋势图
4.2 导航设计
- 采用侧边栏导航
- 一级导航项:首页、资产管理、盘点历史、配置方案、收益分析
- 当前激活项高亮显示
- 支持键盘导航
5. 数据模型概览
5.1 核心实体关系
┌─────────────┐ ┌─────────────────┐
│ Asset │◄────│ SnapshotItem │
│ (资产条目) │ │ (快照明细) │
└─────────────┘ └────────┬────────┘
│
▼
┌─────────────────┐
│ Snapshot │
│ (盘点快照) │
└─────────────────┘
┌─────────────┐ ┌─────────────────┐
│ AllocationPlan │◄────│ Allocation │
│ (配置方案) │ │ (配置项) │
└─────────────┘ └─────────────────┘
5.2 数据量估算
| 实体 | 预估数量(个人用户) |
|---|---|
| 资产条目 | 10 - 50 个 |
| 盘点快照 | 50 - 200 条(5年使用) |
| 配置方案 | 2 - 5 套 |
6. MVP 成功标准
6.1 功能完成标准
| 标准 | 验收条件 |
|---|---|
| 资产盘点 | 能够在 3 分钟内完成 10 个资产条目的盘点 |
| 配置管理 | 能够创建、编辑、切换至少 2 套配置方案 |
| 偏离监控 | 能够清晰看到当前配置与目标配置的偏离情况 |
| 收益分析 | 能够查看季度/年度收益率及各类别收益贡献 |
6.2 技术完成标准
| 标准 | 验收条件 |
|---|---|
| 应用稳定性 | 无崩溃或数据丢失 |
| 性能达标 | 满足 3.1 节性能指标 |
| 代码质量 | Clippy 无警告,代码结构清晰 |
6.3 用户体验标准
| 标准 | 验收条件 |
|---|---|
| 易用性 | 无需文档即可完成基本操作 |
| 视觉一致性 | UI 风格统一,无明显违和感 |
| 操作反馈 | 所有操作有明确的状态反馈 |
7. 范围外事项(Out of Scope)
以下功能明确不在 MVP 范围内:
| 功能 | 延后原因 | 计划版本 |
|---|---|---|
| 自动获取净值 | 需对接外部 API | Phase 3 |
| 分红/调仓记录 | 增加数据模型复杂度 | Phase 2 |
| 具体调仓建议 | 需要更复杂计算逻辑 | Phase 3 |
| 月度收益分析 | MVP 聚焦季度/年度 | Phase 2 |
| 多端同步 | 本地优先 | Phase 4 |
| 数据导入/导出 | 可作为独立迭代 | Phase 4 |
| Windows/Linux 支持 | macOS 优先 | TBD |
8. 开放问题
| 问题 | 状态 | 备选方案 |
|---|---|---|
| 数据存储格式选择 SQLite vs JSON? | 待定 | 倾向 SQLite(查询能力更强) |
| 是否支持多币种? | 决定:MVP 仅支持人民币 | 后续版本考虑 |
| 深色模式是否为 MVP 必需? | 待定 | 建议作为 P1 功能 |
| 子类别层级是否支持嵌套? | 决定:MVP 仅支持一层子类别 | 后续可扩展 |
9. 术语表
| 术语 | 定义 |
|---|---|
| 快照式盘点 | 每次记录资产当前市值的完整状态,不追踪中间变动 |
| 配置偏离度 | 当前资产配置与目标配置的百分比差异 |
| 核心资产 | 追求稳定和安全的资产,通常占比 70-80% |
| 卫星资产 | 追求更高收益、容忍更高波动的资产,通常占比 20-30% |
| 再平衡 | 调整资产配置使其回归目标比例的操作 |
| 收益归因 | 分析各资产/类别对整体收益的贡献程度 |
10. 修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-12-20 | PM Agent | 初始版本,定义 MVP 需求 |
This PRD transforms the Product Brief into detailed, implementable requirements.
Next: Architecture design workflow will define the technical solution based on these requirements.
UI/UX Design Document: asset-light
版本: 1.0.0
日期: 2025-12-20
设计师: UX Designer Agent
1. 设计概述
1.1 设计目标
为 asset-light 打造一个专业、清晰、高效的桌面应用界面,让用户能够:
- 快速完成资产盘点操作
- 一目了然地了解配置偏离情况
- 轻松分析收益来源
1.2 设计原则
| 原则 | 说明 |
|---|---|
| 数据优先 | 财务数据是核心,设计服务于数据的清晰呈现 |
| 操作高效 | 减少点击次数,常用操作触手可及 |
| 视觉层次 | 重要信息突出,次要信息收敛 |
| 状态明确 | 偏离、正常、警告等状态有清晰的视觉区分 |
2. 视觉风格
2.1 色彩系统
主色调:深蓝专业风
Primary Colors (主色)
├── primary-900: #0F172A (深色背景/侧边栏)
├── primary-800: #1E293B (卡片背景)
├── primary-700: #334155 (次级背景)
├── primary-600: #475569 (边框)
├── primary-100: #F1F5F9 (浅色背景)
└── primary-50: #F8FAFC (页面背景)
Accent Colors (强调色)
├── accent-blue: #3B82F6 (主操作按钮、链接)
├── accent-cyan: #06B6D4 (图表高亮)
└── accent-purple: #8B5CF6 (次要强调)
Semantic Colors (语义色)
├── success: #10B981 (正常/正收益/低配可增加)
├── warning: #F59E0B (轻度偏离/注意)
├── danger: #EF4444 (严重偏离/负收益/超配需减少)
└── neutral: #6B7280 (中性/禁用)
Category Colors (类别专用色)
├── cash: #10B981 (现金类 - 绿色系)
├── stable: #3B82F6 (稳健类 - 蓝色系)
└── advanced: #8B5CF6 (进阶类 - 紫色系)
2.2 字体系统
Font Family
├── 中文: "PingFang SC", "Noto Sans SC", sans-serif
└── 数字: "JetBrains Mono", "SF Mono", monospace (金额专用)
Font Sizes
├── display: 32px / 40px (资产总额等大数字)
├── heading1: 24px / 32px (页面标题)
├── heading2: 18px / 24px (区块标题)
├── heading3: 16px / 22px (卡片标题)
├── body: 14px / 20px (正文)
├── caption: 12px / 16px (辅助文字)
└── micro: 10px / 14px (标签/徽章)
2.3 间距系统
Spacing Scale (4px 基准)
├── xs: 4px
├── sm: 8px
├── md: 16px
├── lg: 24px
├── xl: 32px
└── 2xl: 48px
Component Spacing
├── 页面内边距: 32px
├── 卡片内边距: 24px
├── 卡片间距: 16px
├── 列表项间距: 12px
└── 表单项间距: 16px
2.4 圆角和阴影
Border Radius
├── none: 0
├── sm: 4px (按钮、输入框)
├── md: 8px (卡片、模态框)
├── lg: 12px (大卡片)
└── full: 9999px (徽章、头像)
Box Shadow
├── sm: 0 1px 2px rgba(0,0,0,0.05)
├── md: 0 4px 6px rgba(0,0,0,0.07)
├── lg: 0 10px 15px rgba(0,0,0,0.1)
└── inner: inset 0 2px 4px rgba(0,0,0,0.05)
3. 布局结构
3.1 整体布局
┌──────────────────────────────────────────────────────────────┐
│ Window Title Bar │
├────────────┬─────────────────────────────────────────────────┤
│ │ │
│ │ │
│ Sidebar │ Main Content │
│ (220px) │ Area │
│ │ │
│ │ │
│ │ │
│ │ │
└────────────┴─────────────────────────────────────────────────┘
最小窗口尺寸: 1280 x 720
推荐窗口尺寸: 1440 x 900
3.2 侧边栏设计
┌────────────┐
│ ◉ asset │ <- Logo + App Name
│ light │
├────────────┤
│ │
│ ▣ 首页 │ <- Navigation Items
│ ▢ 资产 │ - Icon + Label
│ ▢ 历史 │ - Active state highlight
│ ▢ 配置 │ - Hover effect
│ ▢ 收益 │
│ │
├────────────┤
│ │
│ ⚙ 设置 │ <- Bottom actions
│ │
└────────────┘
Sidebar Specs:
- Width: 220px (fixed)
- Background: primary-900
- Text: white/gray
- Active item: accent-blue background
- Icon size: 20px
- Item height: 44px
- Item padding: 16px horizontal
4. 页面设计
4.1 首页 (资产总览)
页面目标: 一眼看清资产全貌和配置偏离情况
┌─────────────────────────────────────────────────────────────┐
│ 资产总览 上次盘点: 12-15 │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ¥ 1,234,567.89 ││
│ │ 资产总额 ││
│ │ 较上期 +¥12,345.67 (+1.01%) ↑ ││
│ └─────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ 当前配置 │ │ 目标配置 │ │
│ │ ┌────────┐ │ │ ┌────────┐ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ 饼图 │ │ │ │ 饼图 │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └────────┘ │ │ └────────┘ │ │
│ │ ● 现金 25% │ │ ● 现金 20% │ │
│ │ ● 稳健 38% │ │ ● 稳健 40% │ │
│ │ ● 进阶 37% │ │ ● 进阶 40% │ │
│ └──────────────────────┘ └──────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 配置偏离分析 方案: 平衡型 ▾ │
│ ┌──────────┬──────────┬──────────┬──────────┬───────────┐ │
│ │ 类别 │ 当前 │ 目标 │ 偏离 │ 状态 │ │
│ ├──────────┼──────────┼──────────┼──────────┼───────────┤ │
│ │ 现金类 │ 25% │ 20% │ +5% │ ⚠️ 超配 │ │
│ │ 稳健类 │ 38% │ 40% │ -2% │ ✓ 正常 │ │
│ │ 进阶类 │ 37% │ 40% │ -3% │ ✓ 正常 │ │
│ └──────────┴──────────┴──────────┴──────────┴───────────┘ │
└─────────────────────────────────────────────────────────────┘
组件说明:
| 组件 | 规格 |
|---|---|
| 总额卡片 | 高度 160px, 居中布局, 金额使用 display 字号 |
| 饼图卡片 | 并列双卡片, 各占 50% 宽度, 饼图直径 180px |
| 偏离表格 | 全宽, 斑马纹背景, 状态列使用语义色 |
4.2 资产管理页
页面目标: 管理资产条目,执行盘点操作
┌─────────────────────────────────────────────────────────────┐
│ 资产管理 │
│ ────────────────────────────────── │
│ 共 10 项资产 总市值 ¥500,000.00 [开始盘点] [+ 新增] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ▼ 现金类 3项 ¥100,000.00 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ◉ 余额宝 ¥50,000.00 │ │
│ │ 货币基金 [编辑] [删除] │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ ◉ 活期存款 ¥30,000.00 │ │
│ │ 银行活期 [编辑] [删除] │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ ◉ 定期存单 ¥20,000.00 │ │
│ │ 大额存单 [编辑] [删除] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ▼ 稳健类 2项 ¥150,000.00 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ... │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ▼ 进阶类 5项 ¥250,000.00 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ... │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
盘点模式:
┌─────────────────────────────────────────────────────────────┐
│ ⚡ 盘点模式 [完成盘点] [取消盘点] │
│ ─────────────────────────────────────────────────────── │
│ 请更新各资产的当前市值 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ▼ 现金类 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ◉ 余额宝 │ │
│ │ 上次: ¥50,000.00 → 当前: [¥ 51,234.56 ] │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ ◉ 活期存款 │ │
│ │ 上次: ¥30,000.00 → 当前: [¥ 30,100.00 ] │ │
│ └──────────────────────────────────────────────────────┘ │
│ ... │
└─────────────────────────────────────────────────────────────┘
盘点模式特殊样式:
- 顶部提示条: accent-blue 背景
- 输入框高亮: accent-blue 边框
- 上次市值: 灰色小字
4.3 新增/编辑资产表单
表单模态框设计:
┌─────────────────────────────────────────┐
│ 新增资产 ✕ │
├─────────────────────────────────────────┤
│ │
│ 资产名称 * │
│ ┌─────────────────────────────────┐ │
│ │ 请输入资产名称 │ │
│ └─────────────────────────────────┘ │
│ │
│ 资产类别 * │
│ ┌─────────────────────────────────┐ │
│ │ 请选择类别 ▾ │ │
│ └─────────────────────────────────┘ │
│ │
│ 子类别 │
│ ┌─────────────────────────────────┐ │
│ │ 可选,如"宽基指数" │ │
│ └─────────────────────────────────┘ │
│ │
│ 当前市值 * │
│ ┌─────────────────────────────────┐ │
│ │ ¥ 0.00 │ │
│ └─────────────────────────────────┘ │
│ │
│ 备注 │
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ 可选备注信息 │ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ │
├─────────────────────────────────────────┤
│ [取消] [保存资产] │
└─────────────────────────────────────────┘
Modal Specs:
- Width: 480px
- Padding: 24px
- Background: white
- Shadow: lg
- Border radius: md (8px)
4.4 盘点历史页
┌─────────────────────────────────────────────────────────────┐
│ 盘点历史 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─ 2025 ───────────────────────────────────────────────┐ │
│ │ │ │
│ │ ● 12月15日 │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 总市值 ¥1,234,567.89 │ │ │
│ │ │ 较上期 +¥12,345.67 (+1.01%) [>] │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ● 11月15日 │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 总市值 ¥1,222,222.22 │ │ │
│ │ │ 较上期 +¥8,888.88 (+0.73%) [>] │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ● 10月15日 │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 总市值 ¥1,213,333.34 │ │ │
│ │ │ 首次盘点 [>] │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
时间线样式:
- 左侧垂直线: 2px, primary-600
- 时间节点: 12px 圆点, accent-blue
- 正收益: success 色 + ↑ 箭头
- 负收益: danger 色 + ↓ 箭头
4.5 配置方案页
┌─────────────────────────────────────────────────────────────┐
│ 配置方案 [+ 新建方案] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ★ 平衡型 [当前激活] │ │
│ │ ───────────────────────────────────────────────── │ │
│ │ 适合追求稳健增长的投资者 │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ ■ 现金类 20% ■ 稳健类 40% ■ 进阶类 40% │ │ │
│ │ │ ████████████████████████████████████████████ │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [编辑] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 保守型 │ │
│ │ ───────────────────────────────────────────────── │ │
│ │ 适合风险厌恶型投资者 │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ ■ 现金类 30% ■ 稳健类 50% ■ 进阶类 20% │ │ │
│ │ │ ████████████████████████████████████████████ │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [设为当前] [编辑] [删除] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
方案卡片:
- 激活方案: accent-blue 边框 + 星标
- 配置条: 横向堆叠条形图, 使用类别专用色
4.6 配置方案编辑器
┌─────────────────────────────────────────┐
│ 编辑配置方案 ✕ │
├─────────────────────────────────────────┤
│ │
│ 方案名称 * │
│ ┌─────────────────────────────────┐ │
│ │ 平衡型 │ │
│ └─────────────────────────────────┘ │
│ │
│ 方案描述 │
│ ┌─────────────────────────────────┐ │
│ │ 适合追求稳健增长的投资者 │ │
│ └─────────────────────────────────┘ │
│ │
│ ───────────────────────────────── │
│ 目标配置 总计: 100% │
│ ───────────────────────────────── │
│ │
│ 现金类 │
│ ○────────────●──────────○ 20% │
│ 0% 100% │
│ │
│ 稳健类 │
│ ○──────────────────●────○ 40% │
│ 0% 100% │
│ │
│ 进阶类 │
│ ○──────────────────●────○ 40% │
│ 0% 100% │
│ │
│ ┌─────────────────────────────────┐ │
│ │ [饼图预览] │ │
│ └─────────────────────────────────┘ │
│ │
├─────────────────────────────────────────┤
│ [取消] [保存方案] │
└─────────────────────────────────────────┘
配置编辑器:
- 滑块: accent-blue 轨道
- 总计 != 100% 时: 显示 danger 色警告
- 饼图: 实时预览配置分布
4.7 收益分析页
┌─────────────────────────────────────────────────────────────┐
│ 收益分析 │
│ ───────── │
│ 周期: [季度 ▾] 年份: [2025 ▾] 季度: [Q4 ▾] [自定义] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ 本期收益 │ │ 收益率 │ │
│ │ │ │ │ │
│ │ +¥ 12,345.67 │ │ +3.21% │ │
│ │ │ │ │ │
│ │ 10/01 → 12/20 │ │ 期初 ¥384,567 │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────┤
│ 收益归因 │
│ ┌──────────┬────────────┬──────────┬──────────┬─────────┐ │
│ │ 类别 │ 收益金额 │ 收益率 │ 贡献度 │ 可视化 │ │
│ ├──────────┼────────────┼──────────┼──────────┼─────────┤ │
│ │ 现金类 │ +¥1,234 │ +1.5% │ 10% │ ███ │ │
│ │ 稳健类 │ +¥3,456 │ +2.8% │ 28% │ ██████ │ │
│ │ 进阶类 │ +¥7,655 │ +5.2% │ 62% │ █████████│ │
│ └──────────┴────────────┴──────────┴──────────┴─────────┘ │
│ │
├─────────────────────────────────────────────────────────────┤
│ 资产趋势 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ● │ │
│ │ ● │ │
│ │ ● │ │
│ │ ● │ │
│ │ ● │ │
│ │ ● │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ 10/15 10/30 11/15 11/30 12/15 12/20 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
收益卡片:
- 正收益: success 色 + 向上箭头
- 负收益: danger 色 + 向下箭头
- 折线图: accent-cyan 色, 数据点 8px 圆点
5. 组件库
5.1 按钮
Primary Button (主按钮)
┌──────────────────┐
│ 保存资产 │ bg: accent-blue, text: white
└──────────────────┘ hover: darken 10%
height: 40px, padding: 0 20px, radius: 4px
Secondary Button (次按钮)
┌──────────────────┐
│ 取消 │ bg: transparent, text: primary-700
└──────────────────┘ border: 1px primary-600
hover: bg primary-100
Danger Button (危险按钮)
┌──────────────────┐
│ 删除 │ bg: danger, text: white
└──────────────────┘ hover: darken 10%
Ghost Button (幽灵按钮)
┌──────────────────┐
│ 编辑 │ bg: transparent, text: accent-blue
└──────────────────┘ hover: bg accent-blue/10
5.2 输入框
Text Input (文本输入)
┌─────────────────────────────────┐
│ 请输入内容 │ height: 40px
└─────────────────────────────────┘ border: 1px primary-600
focus: border accent-blue
error: border danger
Number Input (数字输入 - 金额)
┌─────────────────────────────────┐
│ ¥ 12,345.67 │ font: monospace
└─────────────────────────────────┘ text-align: right
Select (下拉选择)
┌─────────────────────────────────┐
│ 请选择 ▾ │
└─────────────────────────────────┘
5.3 卡片
Base Card (基础卡片)
┌─────────────────────────────────┐
│ │ bg: white
│ │ border: 1px primary-100
│ │ radius: 8px
│ │ shadow: sm
│ │ padding: 24px
└─────────────────────────────────┘
Stat Card (统计卡片)
┌─────────────────────────────────┐
│ 标题 │ 居中布局
│ ────── │ 大数字突出
│ ¥ 1,234,567 │
│ 说明文字 │
└─────────────────────────────────┘
5.4 状态标签
Status Tags (状态标签)
[✓ 正常] bg: success/10, text: success, radius: full
[⚠️ 超配] bg: warning/10, text: warning, radius: full
[❗ 严重偏离] bg: danger/10, text: danger, radius: full
[当前激活] bg: accent-blue/10, text: accent-blue, radius: full
height: 24px, padding: 0 12px, font-size: 12px
5.5 模态框
Modal (模态框)
┌─────────────────────────────────────────┐
│ 标题 ✕ │ <- Header: border-bottom
├─────────────────────────────────────────┤
│ │
│ 内容区域 │ <- Body: padding 24px
│ │
├─────────────────────────────────────────┤
│ [次按钮] [主按钮] │ <- Footer: border-top
└─────────────────────────────────────────┘
width: 480px (form) / 640px (detail)
max-height: 80vh
背景遮罩: rgba(0,0,0,0.5)
6. 交互规范
6.1 加载状态
| 场景 | 交互 |
|---|---|
| 页面加载 | Skeleton 占位符 |
| 按钮加载 | 按钮内 spinner + 禁用 |
| 列表加载 | 列表区域 spinner |
| 保存操作 | 按钮文字变为"保存中..." |
6.2 反馈提示
Toast 消息 (右上角, 自动消失 3s)
┌────────────────────────────────┐
│ ✓ 资产保存成功 │ success
└────────────────────────────────┘
┌────────────────────────────────┐
│ ✕ 保存失败,请重试 │ danger
└────────────────────────────────┘
6.3 确认对话框
删除确认
┌─────────────────────────────────────────┐
│ 确认删除 ✕ │
├─────────────────────────────────────────┤
│ │
│ ⚠️ 确定删除资产「余额宝」吗? │
│ │
│ 此操作不可撤销。 │
│ │
├─────────────────────────────────────────┤
│ [取消] [确认删除] │
└─────────────────────────────────────────┘
6.4 空状态
无资产时:
┌─────────────────────────────────────────┐
│ │
│ 📦 │
│ │
│ 暂无资产数据 │
│ │
│ 添加您的第一个资产,开始管理财富 │
│ │
│ [+ 添加资产] │
│ │
└─────────────────────────────────────────┘
7. 响应式考虑
| 窗口宽度 | 布局调整 |
|---|---|
| ≥ 1440px | 标准布局 |
| 1280-1439px | 内容区缩窄,保持比例 |
| < 1280px | 不支持(最小宽度限制) |
8. 深色模式 (P1)
深色模式色彩映射:
| 元素 | 浅色模式 | 深色模式 |
|---|---|---|
| 页面背景 | primary-50 | primary-900 |
| 卡片背景 | white | primary-800 |
| 正文颜色 | primary-900 | primary-100 |
| 边框颜色 | primary-200 | primary-700 |
| 强调色 | 保持不变 | 保持不变 |
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-12-20 | UX Designer | 初始版本 |
Technical Architecture Document: asset-light
版本: 1.0.0
日期: 2025-12-20
架构师: Architect Agent
1. 架构概述
1.1 技术栈
| 层面 | 技术选型 | 版本 | 说明 |
|---|---|---|---|
| UI 框架 | Dioxus | 0.5.x | Rust 生态的声明式 UI 框架 |
| 语言 | Rust | 1.75+ | 系统级编程语言 |
| 平台 | Desktop (macOS) | - | 基于 WebView 渲染 |
| 数据库 | SQLite | 3.x | 本地嵌入式数据库 |
| ORM | rusqlite | 0.31.x | SQLite Rust 绑定 |
| 序列化 | serde + serde_json | 1.x | JSON 序列化 |
| 日期时间 | chrono | 0.4.x | 日期时间处理 |
| UUID | uuid | 1.x | 唯一标识生成 |
| 精确数值 | rust_decimal | 1.x | 金融级精度计算 |
1.2 架构模式
采用分层架构 + 组件化 UI模式:
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ (Dioxus Components) │
├─────────────────────────────────────────────────────────────┤
│ Application Layer │
│ (State Management + Hooks) │
├─────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ (Models + Business Logic) │
├─────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ (Database + File System) │
└─────────────────────────────────────────────────────────────┘
2. 项目结构
asset-light/
├── Cargo.toml
├── Dioxus.toml # Dioxus 配置
├── assets/ # 静态资源
│ ├── icons/
│ └── styles/
├── src/
│ ├── main.rs # 应用入口
│ ├── app.rs # 根组件
│ ├── router.rs # 路由定义
│ │
│ ├── components/ # UI 组件
│ │ ├── mod.rs
│ │ ├── layout/ # 布局组件
│ │ │ ├── mod.rs
│ │ │ ├── sidebar.rs
│ │ │ └── page_container.rs
│ │ ├── common/ # 通用组件
│ │ │ ├── mod.rs
│ │ │ ├── button.rs
│ │ │ ├── input.rs
│ │ │ ├── modal.rs
│ │ │ ├── card.rs
│ │ │ ├── toast.rs
│ │ │ └── empty_state.rs
│ │ ├── asset/ # 资产相关组件
│ │ │ ├── mod.rs
│ │ │ ├── asset_list.rs
│ │ │ ├── asset_form.rs
│ │ │ ├── asset_item.rs
│ │ │ └── category_group.rs
│ │ ├── snapshot/ # 盘点相关组件
│ │ │ ├── mod.rs
│ │ │ ├── inventory_mode.rs
│ │ │ ├── snapshot_timeline.rs
│ │ │ └── snapshot_detail.rs
│ │ ├── plan/ # 配置方案组件
│ │ │ ├── mod.rs
│ │ │ ├── plan_list.rs
│ │ │ ├── plan_card.rs
│ │ │ └── plan_editor.rs
│ │ ├── dashboard/ # 首页仪表盘组件
│ │ │ ├── mod.rs
│ │ │ ├── total_card.rs
│ │ │ ├── pie_chart.rs
│ │ │ └── deviation_table.rs
│ │ └── analysis/ # 收益分析组件
│ │ ├── mod.rs
│ │ ├── period_selector.rs
│ │ ├── return_card.rs
│ │ ├── attribution_table.rs
│ │ └── trend_chart.rs
│ │
│ ├── pages/ # 页面组件
│ │ ├── mod.rs
│ │ ├── home.rs # 首页
│ │ ├── assets.rs # 资产管理
│ │ ├── history.rs # 盘点历史
│ │ ├── plans.rs # 配置方案
│ │ └── analysis.rs # 收益分析
│ │
│ ├── models/ # 数据模型
│ │ ├── mod.rs
│ │ ├── asset.rs
│ │ ├── snapshot.rs
│ │ ├── plan.rs
│ │ └── category.rs
│ │
│ ├── services/ # 业务服务
│ │ ├── mod.rs
│ │ ├── asset_service.rs
│ │ ├── snapshot_service.rs
│ │ ├── plan_service.rs
│ │ └── analysis_service.rs
│ │
│ ├── state/ # 状态管理
│ │ ├── mod.rs
│ │ └── app_state.rs
│ │
│ ├── db/ # 数据库层
│ │ ├── mod.rs
│ │ ├── connection.rs
│ │ ├── migrations.rs
│ │ ├── asset_repo.rs
│ │ ├── snapshot_repo.rs
│ │ └── plan_repo.rs
│ │
│ └── utils/ # 工具函数
│ ├── mod.rs
│ ├── decimal.rs
│ ├── date.rs
│ └── format.rs
│
├── migrations/ # SQL 迁移文件
│ ├── 001_create_assets.sql
│ ├── 002_create_snapshots.sql
│ └── 003_create_plans.sql
│
└── docs/ # 项目文档
3. 数据模型
3.1 核心实体
Asset (资产条目)
#![allow(unused)] fn main() { use chrono::{DateTime, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Category { Cash, // 现金类 Stable, // 稳健类 Advanced, // 进阶类 } impl Category { pub fn display_name(&self) -> &str { match self { Category::Cash => "现金类", Category::Stable => "稳健类", Category::Advanced => "进阶类", } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Asset { pub id: Uuid, pub name: String, pub category: Category, pub sub_category: Option<String>, pub current_value: Decimal, pub notes: Option<String>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } impl Asset { pub fn new(name: String, category: Category, current_value: Decimal) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), name, category, sub_category: None, current_value, notes: None, created_at: now, updated_at: now, } } } }
Snapshot (盘点快照)
#![allow(unused)] fn main() { use chrono::{DateTime, NaiveDate, Utc}; use rust_decimal::Decimal; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Snapshot { pub id: Uuid, pub snapshot_date: NaiveDate, pub created_at: DateTime<Utc>, pub total_value: Decimal, pub items: Vec<SnapshotItem>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapshotItem { pub asset_id: Uuid, pub asset_name: String, pub category: Category, pub sub_category: Option<String>, pub value: Decimal, } impl Snapshot { pub fn new(items: Vec<SnapshotItem>) -> Self { let total_value = items.iter() .map(|item| item.value) .sum(); Self { id: Uuid::new_v4(), snapshot_date: Utc::now().date_naive(), created_at: Utc::now(), total_value, items, } } pub fn category_total(&self, category: &Category) -> Decimal { self.items .iter() .filter(|item| &item.category == category) .map(|item| item.value) .sum() } } }
AllocationPlan (配置方案)
#![allow(unused)] fn main() { use rust_decimal::Decimal; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AllocationPlan { pub id: Uuid, pub name: String, pub description: Option<String>, pub is_active: bool, pub allocations: Vec<Allocation>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Allocation { pub category: Category, pub target_percentage: Decimal, pub min_percentage: Option<Decimal>, pub max_percentage: Option<Decimal>, } impl AllocationPlan { pub fn default_balanced() -> Self { Self { id: Uuid::new_v4(), name: "平衡型".to_string(), description: Some("适合追求稳健增长的投资者".to_string()), is_active: true, allocations: vec![ Allocation { category: Category::Cash, target_percentage: Decimal::new(20, 0), min_percentage: None, max_percentage: None, }, Allocation { category: Category::Stable, target_percentage: Decimal::new(40, 0), min_percentage: None, max_percentage: None, }, Allocation { category: Category::Advanced, target_percentage: Decimal::new(40, 0), min_percentage: None, max_percentage: None, }, ], created_at: Utc::now(), updated_at: Utc::now(), } } } }
3.2 派生类型
#![allow(unused)] fn main() { // 偏离分析结果 #[derive(Debug, Clone)] pub struct DeviationResult { pub category: Category, pub current_percentage: Decimal, pub target_percentage: Decimal, pub deviation: Decimal, pub status: DeviationStatus, pub direction: DeviationDirection, } #[derive(Debug, Clone, PartialEq)] pub enum DeviationStatus { Normal, // |偏离| <= 5% Mild, // 5% < |偏离| <= 10% Severe, // |偏离| > 10% } #[derive(Debug, Clone, PartialEq)] pub enum DeviationDirection { Overweight, // 超配 Underweight, // 低配 Balanced, // 平衡 } // 收益分析结果 #[derive(Debug, Clone)] pub struct ReturnAnalysis { pub start_date: NaiveDate, pub end_date: NaiveDate, pub start_value: Decimal, pub end_value: Decimal, pub absolute_return: Decimal, pub return_rate: Decimal, pub category_breakdown: Vec<CategoryReturn>, } #[derive(Debug, Clone)] pub struct CategoryReturn { pub category: Category, pub start_value: Decimal, pub end_value: Decimal, pub absolute_return: Decimal, pub return_rate: Decimal, pub contribution_rate: Decimal, } }
4. 数据库设计
4.1 表结构
assets 表
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('Cash', 'Stable', 'Advanced')),
sub_category TEXT,
current_value TEXT NOT NULL, -- 使用 TEXT 存储 Decimal
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_assets_category ON assets(category);
snapshots 表
CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
snapshot_date TEXT NOT NULL,
created_at TEXT NOT NULL,
total_value TEXT NOT NULL
);
CREATE INDEX idx_snapshots_date ON snapshots(snapshot_date);
snapshot_items 表
CREATE TABLE IF NOT EXISTS snapshot_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
asset_name TEXT NOT NULL,
category TEXT NOT NULL,
sub_category TEXT,
value TEXT NOT NULL,
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
);
CREATE INDEX idx_snapshot_items_snapshot ON snapshot_items(snapshot_id);
allocation_plans 表
CREATE TABLE IF NOT EXISTS allocation_plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
is_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
allocations 表
CREATE TABLE IF NOT EXISTS allocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
category TEXT NOT NULL,
target_percentage TEXT NOT NULL,
min_percentage TEXT,
max_percentage TEXT,
FOREIGN KEY (plan_id) REFERENCES allocation_plans(id) ON DELETE CASCADE
);
CREATE INDEX idx_allocations_plan ON allocations(plan_id);
4.2 数据库连接管理
#![allow(unused)] fn main() { // src/db/connection.rs use rusqlite::{Connection, Result}; use std::path::PathBuf; pub struct Database { conn: Connection, } impl Database { pub fn new() -> Result<Self> { let path = Self::db_path(); std::fs::create_dir_all(path.parent().unwrap()).ok(); let conn = Connection::open(&path)?; conn.execute_batch("PRAGMA foreign_keys = ON;")?; Ok(Self { conn }) } fn db_path() -> PathBuf { let mut path = dirs::data_local_dir() .unwrap_or_else(|| PathBuf::from(".")); path.push("asset-light"); path.push("data.db"); path } pub fn conn(&self) -> &Connection { &self.conn } pub fn run_migrations(&self) -> Result<()> { // 执行迁移脚本 self.conn.execute_batch(include_str!("../../migrations/001_create_assets.sql"))?; self.conn.execute_batch(include_str!("../../migrations/002_create_snapshots.sql"))?; self.conn.execute_batch(include_str!("../../migrations/003_create_plans.sql"))?; Ok(()) } } }
5. 状态管理
5.1 全局状态定义
#![allow(unused)] fn main() { // src/state/app_state.rs use dioxus::prelude::*; use crate::models::{Asset, Snapshot, AllocationPlan}; #[derive(Clone)] pub struct AppState { pub assets: Vec<Asset>, pub snapshots: Vec<Snapshot>, pub plans: Vec<AllocationPlan>, pub active_plan: Option<AllocationPlan>, pub is_inventory_mode: bool, pub loading: bool, pub error: Option<String>, } impl Default for AppState { fn default() -> Self { Self { assets: Vec::new(), snapshots: Vec::new(), plans: Vec::new(), active_plan: None, is_inventory_mode: false, loading: false, error: None, } } } // 全局状态 Context pub fn use_app_state() -> Signal<AppState> { use_context::<Signal<AppState>>() } pub fn provide_app_state() -> Signal<AppState> { use_context_provider(|| Signal::new(AppState::default())) } }
5.2 状态操作
#![allow(unused)] fn main() { // src/state/actions.rs impl AppState { // 资产操作 pub fn add_asset(&mut self, asset: Asset) { self.assets.push(asset); } pub fn update_asset(&mut self, asset: Asset) { if let Some(idx) = self.assets.iter().position(|a| a.id == asset.id) { self.assets[idx] = asset; } } pub fn remove_asset(&mut self, id: Uuid) { self.assets.retain(|a| a.id != id); } // 快照操作 pub fn add_snapshot(&mut self, snapshot: Snapshot) { self.snapshots.insert(0, snapshot); // 最新在前 } // 方案操作 pub fn set_active_plan(&mut self, plan_id: Uuid) { for plan in &mut self.plans { plan.is_active = plan.id == plan_id; } self.active_plan = self.plans.iter() .find(|p| p.is_active) .cloned(); } // 计算属性 pub fn total_value(&self) -> Decimal { self.assets.iter().map(|a| a.current_value).sum() } pub fn category_value(&self, category: &Category) -> Decimal { self.assets .iter() .filter(|a| &a.category == category) .map(|a| a.current_value) .sum() } pub fn category_percentage(&self, category: &Category) -> Decimal { let total = self.total_value(); if total.is_zero() { return Decimal::ZERO; } (self.category_value(category) / total) * Decimal::new(100, 0) } } }
6. 服务层
6.1 资产服务
#![allow(unused)] fn main() { // src/services/asset_service.rs use crate::db::Database; use crate::models::Asset; pub struct AssetService { db: Database, } impl AssetService { pub fn new(db: Database) -> Self { Self { db } } pub fn list_all(&self) -> Result<Vec<Asset>, Error> { AssetRepository::new(self.db.conn()).find_all() } pub fn create(&self, asset: &Asset) -> Result<(), Error> { AssetRepository::new(self.db.conn()).insert(asset) } pub fn update(&self, asset: &Asset) -> Result<(), Error> { AssetRepository::new(self.db.conn()).update(asset) } pub fn delete(&self, id: Uuid) -> Result<(), Error> { AssetRepository::new(self.db.conn()).delete(id) } } }
6.2 快照服务
#![allow(unused)] fn main() { // src/services/snapshot_service.rs pub struct SnapshotService { db: Database, } impl SnapshotService { pub fn create_snapshot(&self, assets: &[Asset]) -> Result<Snapshot, Error> { // 检查每日盘点次数限制 let today_count = self.count_today_snapshots()?; if today_count >= 5 { return Err(Error::LimitExceeded("每天最多盘点5次".to_string())); } // 创建快照 let items: Vec<SnapshotItem> = assets .iter() .map(|a| SnapshotItem { asset_id: a.id, asset_name: a.name.clone(), category: a.category.clone(), sub_category: a.sub_category.clone(), value: a.current_value, }) .collect(); let snapshot = Snapshot::new(items); SnapshotRepository::new(self.db.conn()).insert(&snapshot)?; Ok(snapshot) } pub fn list_all(&self) -> Result<Vec<Snapshot>, Error> { SnapshotRepository::new(self.db.conn()).find_all_ordered() } pub fn get_by_id(&self, id: Uuid) -> Result<Option<Snapshot>, Error> { SnapshotRepository::new(self.db.conn()).find_by_id(id) } } }
6.3 分析服务
#![allow(unused)] fn main() { // src/services/analysis_service.rs pub struct AnalysisService; impl AnalysisService { pub fn calculate_deviation( assets: &[Asset], plan: &AllocationPlan, ) -> Vec<DeviationResult> { let total: Decimal = assets.iter().map(|a| a.current_value).sum(); if total.is_zero() { return vec![]; } plan.allocations .iter() .map(|alloc| { let category_total: Decimal = assets .iter() .filter(|a| a.category == alloc.category) .map(|a| a.current_value) .sum(); let current_pct = (category_total / total) * Decimal::new(100, 0); let target_pct = alloc.target_percentage; let deviation = current_pct - target_pct; let abs_deviation = deviation.abs(); let status = if abs_deviation <= Decimal::new(5, 0) { DeviationStatus::Normal } else if abs_deviation <= Decimal::new(10, 0) { DeviationStatus::Mild } else { DeviationStatus::Severe }; let direction = if deviation > Decimal::ZERO { DeviationDirection::Overweight } else if deviation < Decimal::ZERO { DeviationDirection::Underweight } else { DeviationDirection::Balanced }; DeviationResult { category: alloc.category.clone(), current_percentage: current_pct, target_percentage: target_pct, deviation, status, direction, } }) .collect() } pub fn calculate_return( start_snapshot: &Snapshot, end_snapshot: &Snapshot, ) -> ReturnAnalysis { let absolute_return = end_snapshot.total_value - start_snapshot.total_value; let return_rate = if start_snapshot.total_value > Decimal::ZERO { (absolute_return / start_snapshot.total_value) * Decimal::new(100, 0) } else { Decimal::ZERO }; let categories = vec![Category::Cash, Category::Stable, Category::Advanced]; let category_breakdown: Vec<CategoryReturn> = categories .into_iter() .map(|cat| { let start_val = start_snapshot.category_total(&cat); let end_val = end_snapshot.category_total(&cat); let cat_return = end_val - start_val; let cat_rate = if start_val > Decimal::ZERO { (cat_return / start_val) * Decimal::new(100, 0) } else { Decimal::ZERO }; let contribution = if absolute_return != Decimal::ZERO { (cat_return / absolute_return) * Decimal::new(100, 0) } else { Decimal::ZERO }; CategoryReturn { category: cat, start_value: start_val, end_value: end_val, absolute_return: cat_return, return_rate: cat_rate, contribution_rate: contribution, } }) .collect(); ReturnAnalysis { start_date: start_snapshot.snapshot_date, end_date: end_snapshot.snapshot_date, start_value: start_snapshot.total_value, end_value: end_snapshot.total_value, absolute_return, return_rate, category_breakdown, } } } }
7. 组件架构
7.1 组件层次
App
├── Router
│ ├── Layout
│ │ ├── Sidebar
│ │ │ └── NavItem
│ │ └── PageContainer
│ │ ├── HomePage
│ │ │ ├── TotalCard
│ │ │ ├── PieChartPair
│ │ │ └── DeviationTable
│ │ │
│ │ ├── AssetsPage
│ │ │ ├── AssetList
│ │ │ │ └── CategoryGroup
│ │ │ │ └── AssetItem
│ │ │ ├── AssetFormModal
│ │ │ └── InventoryMode
│ │ │
│ │ ├── HistoryPage
│ │ │ ├── SnapshotTimeline
│ │ │ │ └── SnapshotCard
│ │ │ └── SnapshotDetailModal
│ │ │
│ │ ├── PlansPage
│ │ │ ├── PlanList
│ │ │ │ └── PlanCard
│ │ │ └── PlanEditorModal
│ │ │
│ │ └── AnalysisPage
│ │ ├── PeriodSelector
│ │ ├── ReturnCards
│ │ ├── AttributionTable
│ │ └── TrendChart
│ │
│ └── ModalPortal
│ └── [各类模态框]
7.2 核心组件示例
根组件
#![allow(unused)] fn main() { // src/app.rs use dioxus::prelude::*; use crate::router::Route; use crate::state::provide_app_state; use crate::components::layout::Layout; pub fn App() -> Element { // 提供全局状态 let _state = provide_app_state(); // 初始化数据 use_effect(|| { spawn(async { // 从数据库加载数据 load_initial_data().await; }); }); rsx! { Router::<Route> {} } } }
布局组件
#![allow(unused)] fn main() { // src/components/layout/mod.rs use dioxus::prelude::*; use crate::components::layout::sidebar::Sidebar; #[component] pub fn Layout() -> Element { rsx! { div { class: "flex h-screen bg-gray-50", Sidebar {} main { class: "flex-1 overflow-auto p-8", Outlet::<Route> {} } } } } }
侧边栏
#![allow(unused)] fn main() { // src/components/layout/sidebar.rs use dioxus::prelude::*; use crate::router::Route; #[component] pub fn Sidebar() -> Element { rsx! { nav { class: "w-56 bg-slate-900 text-white flex flex-col", // Logo div { class: "p-6 text-xl font-bold", "asset-light" } // Navigation div { class: "flex-1 px-4 space-y-2", NavItem { to: Route::Home {}, icon: "home", label: "首页" } NavItem { to: Route::Assets {}, icon: "wallet", label: "资产" } NavItem { to: Route::History {}, icon: "clock", label: "历史" } NavItem { to: Route::Plans {}, icon: "target", label: "配置" } NavItem { to: Route::Analysis {}, icon: "chart", label: "收益" } } } } } #[component] fn NavItem(to: Route, icon: &'static str, label: &'static str) -> Element { let current_route = use_route::<Route>(); let is_active = current_route == to; rsx! { Link { to: to, class: if is_active { "flex items-center px-4 py-3 rounded-lg bg-blue-600" } else { "flex items-center px-4 py-3 rounded-lg hover:bg-slate-800" }, span { class: "mr-3", "{icon}" } span { "{label}" } } } } }
8. 路由设计
#![allow(unused)] fn main() { // src/router.rs use dioxus::prelude::*; use crate::pages::*; use crate::components::layout::Layout; #[derive(Routable, Clone, PartialEq)] pub enum Route { #[layout(Layout)] #[route("/")] Home {}, #[route("/assets")] Assets {}, #[route("/history")] History {}, #[route("/plans")] Plans {}, #[route("/analysis")] Analysis {}, #[end_layout] #[route("/:..route")] NotFound { route: Vec<String> }, } }
9. 错误处理
#![allow(unused)] fn main() { // src/utils/error.rs use thiserror::Error; #[derive(Error, Debug)] pub enum AppError { #[error("数据库错误: {0}")] Database(#[from] rusqlite::Error), #[error("验证失败: {0}")] Validation(String), #[error("操作限制: {0}")] LimitExceeded(String), #[error("未找到: {0}")] NotFound(String), } pub type Result<T> = std::result::Result<T, AppError>; }
10. 配置文件
Cargo.toml
[package]
name = "asset-light"
version = "0.1.0"
edition = "2021"
[dependencies]
dioxus = { version = "0.5", features = ["desktop", "router"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rust_decimal = { version = "1.0", features = ["serde"] }
dirs = "5.0"
thiserror = "1.0"
tokio = { version = "1.0", features = ["full"] }
[profile.release]
opt-level = "z"
lto = true
Dioxus.toml
[application]
name = "asset-light"
default_platform = "desktop"
[desktop]
title = "asset-light"
min_width = 1280
min_height = 720
[desktop.window]
title = "asset-light - 个人资产管理"
resizable = true
decorations = true
11. 性能考虑
| 方面 | 策略 |
|---|---|
| 启动速度 | 延迟加载非首页数据 |
| 列表渲染 | 虚拟滚动(大数据量时) |
| 状态更新 | 细粒度 Signal,避免全量刷新 |
| 数据库 | 索引优化,批量操作 |
| 内存 | 及时释放大对象引用 |
12. 安全考虑
| 方面 | 措施 |
|---|---|
| 数据存储 | 本地 SQLite,无网络传输 |
| 输入验证 | 服务层统一验证 |
| SQL 注入 | 使用参数化查询 |
| 文件访问 | 仅访问应用专属目录 |
13. 开发路线图
阶段 1:基础设施 (Week 1)
- 项目初始化
- 数据库层实现
- 基础组件库
- 路由和布局
阶段 2:核心功能 (Week 2-3)
- 资产 CRUD
- 盘点功能
- 快照存储
阶段 3:配置和分析 (Week 4)
- 配置方案管理
- 偏离度计算
- 收益分析
阶段 4:完善优化 (Week 5)
- UI 打磨
- 性能优化
- 错误处理
- 测试
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-12-20 | Architect | 初始版本 |
Test Design Document: asset-light
版本: 1.0.0
日期: 2025-12-20
测试工程师: TEA Agent
1. 测试概述
1.1 测试目标
为 asset-light MVP 建立全面的测试策略,确保:
- 核心业务逻辑正确性
- 数据持久化可靠性
- UI 交互符合预期
- 性能指标达标
1.2 测试范围
| 模块 | 测试类型 | 优先级 |
|---|---|---|
| 数据模型 | 单元测试 | P0 |
| 业务服务 | 单元测试 + 集成测试 | P0 |
| 数据库层 | 集成测试 | P0 |
| 组件渲染 | 组件测试 | P1 |
| 端到端流程 | E2E 测试 | P1 |
| 性能基准 | 性能测试 | P2 |
1.3 测试工具
| 用途 | 工具 |
|---|---|
| 单元测试 | Rust 内置 #[test] |
| 断言增强 | pretty_assertions |
| Mock | mockall |
| 异步测试 | tokio::test |
| 测试覆盖率 | cargo-tarpaulin |
| 基准测试 | criterion |
2. 测试策略
2.1 测试金字塔
┌───────────┐
│ E2E │ 少量关键流程
│ Tests │
├───────────┤
│Integration│ 服务 + 数据库
│ Tests │
├───────────┤
│ Unit │ 大量覆盖
│ Tests │ 模型 + 服务 + 工具
└───────────┘
2.2 覆盖率目标
| 层级 | 目标覆盖率 |
|---|---|
| 模型层 (models) | ≥ 90% |
| 服务层 (services) | ≥ 85% |
| 数据库层 (db) | ≥ 80% |
| 工具函数 (utils) | ≥ 90% |
| 整体 | ≥ 80% |
3. 单元测试
3.1 模型层测试
3.1.1 Asset 模型
测试文件: src/models/asset.rs
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use rust_decimal_macros::dec; #[test] fn test_asset_new() { let asset = Asset::new( "余额宝".to_string(), Category::Cash, dec!(10000.00), ); assert!(!asset.id.is_nil()); assert_eq!(asset.name, "余额宝"); assert_eq!(asset.category, Category::Cash); assert_eq!(asset.current_value, dec!(10000.00)); assert!(asset.sub_category.is_none()); assert!(asset.notes.is_none()); } #[test] fn test_category_display_name() { assert_eq!(Category::Cash.display_name(), "现金类"); assert_eq!(Category::Stable.display_name(), "稳健类"); assert_eq!(Category::Advanced.display_name(), "进阶类"); } #[test] fn test_asset_with_sub_category() { let mut asset = Asset::new( "沪深300ETF".to_string(), Category::Advanced, dec!(50000.00), ); asset.sub_category = Some("宽基指数".to_string()); assert_eq!(asset.sub_category, Some("宽基指数".to_string())); } } }
3.1.2 Snapshot 模型
测试文件: src/models/snapshot.rs
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use rust_decimal_macros::dec; #[test] fn test_snapshot_new_calculates_total() { let items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产A".to_string(), category: Category::Cash, sub_category: None, value: dec!(10000.00), }, SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产B".to_string(), category: Category::Stable, sub_category: None, value: dec!(20000.00), }, ]; let snapshot = Snapshot::new(items); assert_eq!(snapshot.total_value, dec!(30000.00)); assert_eq!(snapshot.items.len(), 2); } #[test] fn test_snapshot_category_total() { let items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "现金A".to_string(), category: Category::Cash, sub_category: None, value: dec!(10000.00), }, SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "现金B".to_string(), category: Category::Cash, sub_category: None, value: dec!(5000.00), }, SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "稳健".to_string(), category: Category::Stable, sub_category: None, value: dec!(20000.00), }, ]; let snapshot = Snapshot::new(items); assert_eq!(snapshot.category_total(&Category::Cash), dec!(15000.00)); assert_eq!(snapshot.category_total(&Category::Stable), dec!(20000.00)); assert_eq!(snapshot.category_total(&Category::Advanced), dec!(0.00)); } #[test] fn test_snapshot_empty_items() { let snapshot = Snapshot::new(vec![]); assert_eq!(snapshot.total_value, dec!(0.00)); assert!(snapshot.items.is_empty()); } } }
3.1.3 AllocationPlan 模型
测试文件: src/models/plan.rs
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use rust_decimal_macros::dec; #[test] fn test_default_balanced_plan() { let plan = AllocationPlan::default_balanced(); assert_eq!(plan.name, "平衡型"); assert!(plan.is_active); assert_eq!(plan.allocations.len(), 3); let total: Decimal = plan.allocations .iter() .map(|a| a.target_percentage) .sum(); assert_eq!(total, dec!(100)); } #[test] fn test_template_conservative() { let plan = AllocationPlan::template_conservative(); let cash = plan.allocations.iter() .find(|a| a.category == Category::Cash) .unwrap(); assert_eq!(cash.target_percentage, dec!(30)); } #[test] fn test_template_aggressive() { let plan = AllocationPlan::template_aggressive(); let advanced = plan.allocations.iter() .find(|a| a.category == Category::Advanced) .unwrap(); assert_eq!(advanced.target_percentage, dec!(70)); } } }
3.2 服务层测试
3.2.1 AnalysisService 测试
测试文件: src/services/analysis_service.rs
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use rust_decimal_macros::dec; fn create_test_assets() -> Vec<Asset> { vec![ Asset { id: Uuid::new_v4(), name: "现金".to_string(), category: Category::Cash, sub_category: None, current_value: dec!(25000.00), // 25% notes: None, created_at: Utc::now(), updated_at: Utc::now(), }, Asset { id: Uuid::new_v4(), name: "稳健".to_string(), category: Category::Stable, sub_category: None, current_value: dec!(40000.00), // 40% notes: None, created_at: Utc::now(), updated_at: Utc::now(), }, Asset { id: Uuid::new_v4(), name: "进阶".to_string(), category: Category::Advanced, sub_category: None, current_value: dec!(35000.00), // 35% notes: None, created_at: Utc::now(), updated_at: Utc::now(), }, ] } fn create_test_plan() -> AllocationPlan { AllocationPlan { id: Uuid::new_v4(), name: "测试方案".to_string(), description: None, is_active: true, allocations: vec![ Allocation { category: Category::Cash, target_percentage: dec!(20), min_percentage: None, max_percentage: None, }, Allocation { category: Category::Stable, target_percentage: dec!(40), min_percentage: None, max_percentage: None, }, Allocation { category: Category::Advanced, target_percentage: dec!(40), min_percentage: None, max_percentage: None, }, ], created_at: Utc::now(), updated_at: Utc::now(), } } #[test] fn test_calculate_deviation_normal() { let assets = create_test_assets(); let plan = create_test_plan(); let results = AnalysisService::calculate_deviation(&assets, &plan); assert_eq!(results.len(), 3); // 现金类: 25% - 20% = +5% (超配,轻度偏离) let cash = results.iter().find(|r| r.category == Category::Cash).unwrap(); assert_eq!(cash.deviation, dec!(5)); assert_eq!(cash.status, DeviationStatus::Mild); assert_eq!(cash.direction, DeviationDirection::Overweight); // 稳健类: 40% - 40% = 0% (正常) let stable = results.iter().find(|r| r.category == Category::Stable).unwrap(); assert_eq!(stable.deviation, dec!(0)); assert_eq!(stable.status, DeviationStatus::Normal); // 进阶类: 35% - 40% = -5% (低配,正常边界) let advanced = results.iter().find(|r| r.category == Category::Advanced).unwrap(); assert_eq!(advanced.deviation, dec!(-5)); assert_eq!(advanced.status, DeviationStatus::Normal); assert_eq!(advanced.direction, DeviationDirection::Underweight); } #[test] fn test_calculate_deviation_severe() { let assets = vec![ Asset { id: Uuid::new_v4(), name: "现金".to_string(), category: Category::Cash, sub_category: None, current_value: dec!(50000.00), // 50% notes: None, created_at: Utc::now(), updated_at: Utc::now(), }, Asset { id: Uuid::new_v4(), name: "进阶".to_string(), category: Category::Advanced, sub_category: None, current_value: dec!(50000.00), // 50% notes: None, created_at: Utc::now(), updated_at: Utc::now(), }, ]; let plan = create_test_plan(); let results = AnalysisService::calculate_deviation(&assets, &plan); // 现金类: 50% - 20% = +30% (严重偏离) let cash = results.iter().find(|r| r.category == Category::Cash).unwrap(); assert_eq!(cash.status, DeviationStatus::Severe); } #[test] fn test_calculate_deviation_empty_assets() { let assets: Vec<Asset> = vec![]; let plan = create_test_plan(); let results = AnalysisService::calculate_deviation(&assets, &plan); assert!(results.is_empty()); } #[test] fn test_calculate_return() { let start_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产".to_string(), category: Category::Cash, sub_category: None, value: dec!(100000.00), }, ]; let end_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产".to_string(), category: Category::Cash, sub_category: None, value: dec!(110000.00), }, ]; let start = Snapshot::new(start_items); let end = Snapshot::new(end_items); let result = AnalysisService::calculate_return(&start, &end); assert_eq!(result.absolute_return, dec!(10000.00)); assert_eq!(result.return_rate, dec!(10)); // 10% } #[test] fn test_calculate_return_negative() { let start_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产".to_string(), category: Category::Advanced, sub_category: None, value: dec!(100000.00), }, ]; let end_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产".to_string(), category: Category::Advanced, sub_category: None, value: dec!(90000.00), }, ]; let start = Snapshot::new(start_items); let end = Snapshot::new(end_items); let result = AnalysisService::calculate_return(&start, &end); assert_eq!(result.absolute_return, dec!(-10000.00)); assert_eq!(result.return_rate, dec!(-10)); // -10% } #[test] fn test_calculate_return_zero_start() { let start = Snapshot::new(vec![]); let end_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产".to_string(), category: Category::Cash, sub_category: None, value: dec!(10000.00), }, ]; let end = Snapshot::new(end_items); let result = AnalysisService::calculate_return(&start, &end); assert_eq!(result.return_rate, dec!(0)); // 避免除零 } #[test] fn test_category_return_contribution() { let start_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "现金".to_string(), category: Category::Cash, sub_category: None, value: dec!(50000.00), }, SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "进阶".to_string(), category: Category::Advanced, sub_category: None, value: dec!(50000.00), }, ]; let end_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "现金".to_string(), category: Category::Cash, sub_category: None, value: dec!(51000.00), // +1000 }, SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "进阶".to_string(), category: Category::Advanced, sub_category: None, value: dec!(59000.00), // +9000 }, ]; let start = Snapshot::new(start_items); let end = Snapshot::new(end_items); let result = AnalysisService::calculate_return(&start, &end); // 总收益 10000,现金贡献 10%,进阶贡献 90% let cash_contrib = result.category_breakdown .iter() .find(|c| c.category == Category::Cash) .unwrap(); assert_eq!(cash_contrib.contribution_rate, dec!(10)); let advanced_contrib = result.category_breakdown .iter() .find(|c| c.category == Category::Advanced) .unwrap(); assert_eq!(advanced_contrib.contribution_rate, dec!(90)); } } }
3.3 工具函数测试
3.3.1 金额格式化
测试文件: src/utils/format.rs
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use rust_decimal_macros::dec; #[test] fn test_format_currency() { assert_eq!(format_currency(dec!(1234567.89)), "¥ 1,234,567.89"); assert_eq!(format_currency(dec!(0)), "¥ 0.00"); assert_eq!(format_currency(dec!(-1234.56)), "-¥ 1,234.56"); } #[test] fn test_format_percentage() { assert_eq!(format_percentage(dec!(25.5)), "25.50%"); assert_eq!(format_percentage(dec!(0)), "0.00%"); assert_eq!(format_percentage(dec!(-5.25)), "-5.25%"); } #[test] fn test_format_change() { assert_eq!(format_change(dec!(1234.56)), "+¥ 1,234.56"); assert_eq!(format_change(dec!(-1234.56)), "-¥ 1,234.56"); assert_eq!(format_change(dec!(0)), "¥ 0.00"); } #[test] fn test_format_change_percentage() { assert_eq!(format_change_percentage(dec!(5.25)), "+5.25%"); assert_eq!(format_change_percentage(dec!(-3.5)), "-3.50%"); } } }
3.3.2 日期处理
测试文件: src/utils/date.rs
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use chrono::NaiveDate; #[test] fn test_get_quarter() { assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()), 1); assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 4, 1).unwrap()), 2); assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 7, 31).unwrap()), 3); assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()), 4); } #[test] fn test_get_quarter_date_range() { let (start, end) = get_quarter_date_range(2025, 1); assert_eq!(start, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()); assert_eq!(end, NaiveDate::from_ymd_opt(2025, 3, 31).unwrap()); let (start, end) = get_quarter_date_range(2025, 4); assert_eq!(start, NaiveDate::from_ymd_opt(2025, 10, 1).unwrap()); assert_eq!(end, NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()); } #[test] fn test_format_date_display() { let date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap(); assert_eq!(format_date_display(date), "2025-12-20"); } #[test] fn test_format_date_friendly() { let date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap(); assert_eq!(format_date_friendly(date), "12月20日"); } } }
4. 集成测试
4.1 数据库层集成测试
测试文件: tests/db_integration.rs
#![allow(unused)] fn main() { use asset_light::db::{Database, AssetRepository, SnapshotRepository, PlanRepository}; use asset_light::models::*; use rust_decimal_macros::dec; use tempfile::TempDir; fn setup_test_db() -> (Database, TempDir) { let temp_dir = TempDir::new().unwrap(); let db_path = temp_dir.path().join("test.db"); std::env::set_var("ASSET_LIGHT_DB_PATH", db_path.to_str().unwrap()); let db = Database::new().unwrap(); db.run_migrations().unwrap(); (db, temp_dir) } #[test] fn test_asset_crud() { let (db, _temp) = setup_test_db(); let repo = AssetRepository::new(db.conn()); // Create let asset = Asset::new("测试资产".to_string(), Category::Cash, dec!(10000)); repo.insert(&asset).unwrap(); // Read let found = repo.find_by_id(asset.id).unwrap().unwrap(); assert_eq!(found.name, "测试资产"); // Update let mut updated = found.clone(); updated.current_value = dec!(20000); repo.update(&updated).unwrap(); let found = repo.find_by_id(asset.id).unwrap().unwrap(); assert_eq!(found.current_value, dec!(20000)); // Delete repo.delete(asset.id).unwrap(); let found = repo.find_by_id(asset.id).unwrap(); assert!(found.is_none()); } #[test] fn test_asset_find_by_category() { let (db, _temp) = setup_test_db(); let repo = AssetRepository::new(db.conn()); repo.insert(&Asset::new("现金1".to_string(), Category::Cash, dec!(1000))).unwrap(); repo.insert(&Asset::new("现金2".to_string(), Category::Cash, dec!(2000))).unwrap(); repo.insert(&Asset::new("稳健".to_string(), Category::Stable, dec!(3000))).unwrap(); let cash_assets = repo.find_by_category(Category::Cash).unwrap(); assert_eq!(cash_assets.len(), 2); let stable_assets = repo.find_by_category(Category::Stable).unwrap(); assert_eq!(stable_assets.len(), 1); } #[test] fn test_snapshot_with_items() { let (db, _temp) = setup_test_db(); let asset_repo = AssetRepository::new(db.conn()); let snapshot_repo = SnapshotRepository::new(db.conn()); // 创建资产 let asset = Asset::new("资产".to_string(), Category::Cash, dec!(10000)); asset_repo.insert(&asset).unwrap(); // 创建快照 let items = vec![SnapshotItem { asset_id: asset.id, asset_name: asset.name.clone(), category: asset.category.clone(), sub_category: None, value: asset.current_value, }]; let snapshot = Snapshot::new(items); snapshot_repo.insert(&snapshot).unwrap(); // 查询快照 let found = snapshot_repo.find_by_id(snapshot.id).unwrap().unwrap(); assert_eq!(found.items.len(), 1); assert_eq!(found.total_value, dec!(10000)); } #[test] fn test_plan_activation() { let (db, _temp) = setup_test_db(); let repo = PlanRepository::new(db.conn()); // 创建两个方案 let plan1 = AllocationPlan::default_balanced(); let mut plan2 = AllocationPlan::template_conservative(); plan2.is_active = false; repo.insert(&plan1).unwrap(); repo.insert(&plan2).unwrap(); // 激活 plan2 repo.set_active(plan2.id).unwrap(); // 验证 let found1 = repo.find_by_id(plan1.id).unwrap().unwrap(); let found2 = repo.find_by_id(plan2.id).unwrap().unwrap(); assert!(!found1.is_active); assert!(found2.is_active); // 查询激活方案 let active = repo.find_active().unwrap().unwrap(); assert_eq!(active.id, plan2.id); } #[test] fn test_snapshot_daily_limit() { let (db, _temp) = setup_test_db(); let repo = SnapshotRepository::new(db.conn()); // 创建 5 个快照 for i in 0..5 { let snapshot = Snapshot::new(vec![]); repo.insert(&snapshot).unwrap(); } // 第 6 个应该检查失败 let count = repo.count_today().unwrap(); assert_eq!(count, 5); } }
5. 业务流程测试
5.1 盘点流程测试
#![allow(unused)] fn main() { #[cfg(test)] mod inventory_flow_tests { use super::*; #[test] fn test_complete_inventory_flow() { let (db, _temp) = setup_test_db(); let asset_service = AssetService::new(db.clone()); let snapshot_service = SnapshotService::new(db.clone()); // 1. 创建资产 let asset1 = Asset::new("资产1".to_string(), Category::Cash, dec!(10000)); let asset2 = Asset::new("资产2".to_string(), Category::Stable, dec!(20000)); asset_service.create(&asset1).unwrap(); asset_service.create(&asset2).unwrap(); // 2. 执行盘点 let assets = asset_service.list_all().unwrap(); let snapshot = snapshot_service.create_snapshot(&assets).unwrap(); assert_eq!(snapshot.total_value, dec!(30000)); assert_eq!(snapshot.items.len(), 2); // 3. 更新资产 let mut updated = asset1.clone(); updated.current_value = dec!(15000); asset_service.update(&updated).unwrap(); // 4. 再次盘点 let assets = asset_service.list_all().unwrap(); let snapshot2 = snapshot_service.create_snapshot(&assets).unwrap(); assert_eq!(snapshot2.total_value, dec!(35000)); // 5. 验证历史 let history = snapshot_service.list_all().unwrap(); assert_eq!(history.len(), 2); } #[test] fn test_inventory_daily_limit() { let (db, _temp) = setup_test_db(); let asset_service = AssetService::new(db.clone()); let snapshot_service = SnapshotService::new(db.clone()); // 创建资产 let asset = Asset::new("资产".to_string(), Category::Cash, dec!(10000)); asset_service.create(&asset).unwrap(); // 盘点 5 次 for _ in 0..5 { let assets = asset_service.list_all().unwrap(); snapshot_service.create_snapshot(&assets).unwrap(); } // 第 6 次应该失败 let assets = asset_service.list_all().unwrap(); let result = snapshot_service.create_snapshot(&assets); assert!(result.is_err()); match result { Err(AppError::LimitExceeded(msg)) => { assert!(msg.contains("5次")); } _ => panic!("Expected LimitExceeded error"), } } } }
5.2 收益分析流程测试
#![allow(unused)] fn main() { #[cfg(test)] mod analysis_flow_tests { use super::*; #[test] fn test_quarterly_return_analysis() { let (db, _temp) = setup_test_db(); let snapshot_repo = SnapshotRepository::new(db.conn()); // 创建 Q4 的两个快照 let start_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产".to_string(), category: Category::Cash, sub_category: None, value: dec!(100000), }, ]; let mut start = Snapshot::new(start_items); start.snapshot_date = NaiveDate::from_ymd_opt(2025, 10, 1).unwrap(); snapshot_repo.insert(&start).unwrap(); let end_items = vec![ SnapshotItem { asset_id: Uuid::new_v4(), asset_name: "资产".to_string(), category: Category::Cash, sub_category: None, value: dec!(105000), }, ]; let mut end = Snapshot::new(end_items); end.snapshot_date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap(); snapshot_repo.insert(&end).unwrap(); // 查询 Q4 收益 let (q_start, q_end) = get_quarter_date_range(2025, 4); let snapshots = snapshot_repo.find_in_range(q_start, q_end).unwrap(); assert_eq!(snapshots.len(), 2); let result = AnalysisService::calculate_return( snapshots.last().unwrap(), // 最早 snapshots.first().unwrap(), // 最新 ); assert_eq!(result.absolute_return, dec!(5000)); assert_eq!(result.return_rate, dec!(5)); } } }
6. 边界条件测试
6.1 数据边界
#![allow(unused)] fn main() { #[cfg(test)] mod boundary_tests { use super::*; #[test] fn test_asset_name_max_length() { let name = "a".repeat(100); let asset = Asset::new(name.clone(), Category::Cash, dec!(1000)); assert_eq!(asset.name.len(), 100); // 超过 100 字符应该验证失败 let long_name = "a".repeat(101); let result = validate_asset_name(&long_name); assert!(result.is_err()); } #[test] fn test_asset_value_zero() { let asset = Asset::new("零值资产".to_string(), Category::Cash, dec!(0)); assert_eq!(asset.current_value, dec!(0)); } #[test] fn test_asset_value_negative_rejected() { let result = validate_asset_value(dec!(-100)); assert!(result.is_err()); } #[test] fn test_asset_value_precision() { // 保留 2 位小数 let asset = Asset::new("资产".to_string(), Category::Cash, dec!(1234.567)); // 应该被截断或四舍五入到 1234.57 assert_eq!(asset.current_value.scale(), 2); } #[test] fn test_allocation_total_not_100() { let plan = AllocationPlan { id: Uuid::new_v4(), name: "错误方案".to_string(), description: None, is_active: false, allocations: vec![ Allocation { category: Category::Cash, target_percentage: dec!(30), min_percentage: None, max_percentage: None, }, Allocation { category: Category::Stable, target_percentage: dec!(30), min_percentage: None, max_percentage: None, }, // 缺少 Advanced,总和只有 60% ], created_at: Utc::now(), updated_at: Utc::now(), }; let result = validate_allocation_plan(&plan); assert!(result.is_err()); } #[test] fn test_empty_assets_deviation() { let assets: Vec<Asset> = vec![]; let plan = AllocationPlan::default_balanced(); let results = AnalysisService::calculate_deviation(&assets, &plan); assert!(results.is_empty()); } #[test] fn test_single_snapshot_return() { // 只有一个快照时无法计算收益 let snapshot = Snapshot::new(vec![]); // 应该返回特殊值或错误 // 具体取决于 API 设计 } } }
7. 性能测试
7.1 基准测试
文件: benches/performance.rs
#![allow(unused)] fn main() { use criterion::{black_box, criterion_group, criterion_main, Criterion}; use asset_light::services::AnalysisService; use asset_light::models::*; use rust_decimal_macros::dec; use uuid::Uuid; fn create_large_asset_list(count: usize) -> Vec<Asset> { (0..count) .map(|i| Asset { id: Uuid::new_v4(), name: format!("资产{}", i), category: match i % 3 { 0 => Category::Cash, 1 => Category::Stable, _ => Category::Advanced, }, sub_category: None, current_value: dec!(10000), notes: None, created_at: Utc::now(), updated_at: Utc::now(), }) .collect() } fn bench_deviation_calculation(c: &mut Criterion) { let assets = create_large_asset_list(100); let plan = AllocationPlan::default_balanced(); c.bench_function("calculate_deviation_100_assets", |b| { b.iter(|| { AnalysisService::calculate_deviation( black_box(&assets), black_box(&plan), ) }) }); } fn bench_return_calculation(c: &mut Criterion) { let items: Vec<SnapshotItem> = (0..100) .map(|i| SnapshotItem { asset_id: Uuid::new_v4(), asset_name: format!("资产{}", i), category: Category::Cash, sub_category: None, value: dec!(10000), }) .collect(); let start = Snapshot::new(items.clone()); let end = Snapshot::new(items); c.bench_function("calculate_return_100_items", |b| { b.iter(|| { AnalysisService::calculate_return( black_box(&start), black_box(&end), ) }) }); } criterion_group!(benches, bench_deviation_calculation, bench_return_calculation); criterion_main!(benches); }
7.2 性能指标
| 场景 | 目标 | 验收标准 |
|---|---|---|
| 应用启动 | < 2s | 冷启动到可交互 |
| 页面切换 | < 200ms | 点击到渲染完成 |
| 盘点保存 | < 500ms | 100 个资产 |
| 偏离计算 | < 50ms | 100 个资产 |
| 收益计算 | < 100ms | 1000 条快照 |
8. 测试执行
8.1 测试命令
# 运行所有测试
cargo test
# 运行单元测试
cargo test --lib
# 运行集成测试
cargo test --test '*'
# 运行特定模块测试
cargo test models::
cargo test services::
# 显示详细输出
cargo test -- --nocapture
# 运行性能基准
cargo bench
# 生成覆盖率报告
cargo tarpaulin --out Html
8.2 CI 集成
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test --all
- name: Run clippy
run: cargo clippy -- -D warnings
9. 测试清单
9.1 模型层测试清单
| 测试 | 状态 |
|---|---|
| Asset::new() 创建 | ⬜ |
| Category 枚举显示名 | ⬜ |
| Snapshot 总值计算 | ⬜ |
| Snapshot 类别汇总 | ⬜ |
| AllocationPlan 模板 | ⬜ |
9.2 服务层测试清单
| 测试 | 状态 |
|---|---|
| 偏离度正常计算 | ⬜ |
| 偏离度严重偏离 | ⬜ |
| 偏离度空资产 | ⬜ |
| 收益正向计算 | ⬜ |
| 收益负向计算 | ⬜ |
| 收益贡献度 | ⬜ |
| 每日盘点限制 | ⬜ |
9.3 集成测试清单
| 测试 | 状态 |
|---|---|
| 资产 CRUD | ⬜ |
| 快照存储 | ⬜ |
| 方案激活切换 | ⬜ |
| 完整盘点流程 | ⬜ |
| 季度收益分析 | ⬜ |
9.4 边界测试清单
| 测试 | 状态 |
|---|---|
| 名称最大长度 | ⬜ |
| 零值资产 | ⬜ |
| 负值拒绝 | ⬜ |
| 占比非 100% | ⬜ |
| 空资产偏离 | ⬜ |
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-12-20 | TEA Agent | 初始版本 |
Epics and Stories: asset-light MVP
版本: 1.0.0
日期: 2025-12-20
关联 PRD: PRD
概览
本文档将 PRD 中的功能需求拆分为可执行的 Epics 和 Stories,用于指导开发迭代。
Epic 总览
| Epic ID | 名称 | Stories 数量 | 优先级 |
|---|---|---|---|
| E0 | 项目基础设施 | 4 | P0 |
| E1 | 资产盘点模块 | 8 | P0 |
| E2 | 配置方案模块 | 6 | P0 |
| E3 | 资产视图模块 | 5 | P0 |
| E4 | 收益分析模块 | 5 | P1 |
优先级定义
- P0: MVP 必须完成,阻塞发布
- P1: MVP 应该完成,可降级
- P2: MVP 可选,后续迭代
E0: 项目基础设施
目标: 搭建项目骨架,建立开发基础
E0-S1: 项目初始化
优先级: P0
描述:
作为开发者,我需要初始化 Dioxus 桌面项目,配置基础开发环境,以便开始功能开发。
验收标准:
- 创建 Cargo 项目,配置 Dioxus Desktop 依赖
- 配置 macOS 应用 bundle 设置(应用名称、图标占位)
- 应用能够成功编译并启动空白窗口
- 建立基础目录结构(src/components, src/models, src/services)
- 配置 rustfmt 和 clippy
技术备注:
依赖: dioxus = { version = "0.5", features = ["desktop"] }
E0-S2: 数据持久化层
优先级: P0
描述:
作为开发者,我需要建立本地数据存储机制,以便应用能够保存和读取用户数据。
验收标准:
- 选定并集成 SQLite 作为本地数据库
- 实现数据库初始化和迁移机制
- 创建基础的 CRUD 操作 trait
- 数据文件存储在用户目录下的应用专属位置
- 错误处理:数据库损坏时给出友好提示
技术备注:
建议使用 rusqlite 或 sqlx (with sqlite feature)
数据路径: ~/Library/Application Support/asset-light/
E0-S3: 应用布局框架
优先级: P0
描述:
作为用户,我希望应用有清晰的导航结构,以便快速切换不同功能模块。
验收标准:
- 实现侧边栏导航组件
- 导航项包含:首页、资产管理、盘点历史、配置方案、收益分析
- 当前激活项有视觉高亮
- 路由系统支持页面切换
- 页面切换响应时间 < 200ms
界面参考:
┌─────────┬────────────────────────────────┐
│ 侧边栏 │ │
│ ────── │ 主内容区域 │
│ 首页 │ │
│ 资产 │ │
│ 历史 │ │
│ 配置 │ │
│ 收益 │ │
└─────────┴────────────────────────────────┘
E0-S4: 全局状态管理
优先级: P0
描述:
作为开发者,我需要建立全局状态管理机制,以便跨组件共享数据(如当前资产列表、激活配置方案)。
验收标准:
- 使用 Dioxus Signal 或 Context 实现全局状态
- 定义核心状态结构:AppState(资产列表、快照列表、配置方案列表、当前激活方案)
- 状态变更能触发相关组件重渲染
- 状态持久化:应用启动时从数据库加载,变更时自动保存
E1: 资产盘点模块
目标: 实现资产条目的增删改查和快照式盘点功能
E1-S1: 资产条目数据模型
优先级: P0
PRD 引用: FR-F1-01, FR-F1-02
描述:
作为开发者,我需要定义资产条目的数据模型和数据库表结构。
验收标准:
- 定义 Asset 结构体,包含:id, name, category, sub_category, current_value, notes, created_at, updated_at
- 定义 Category 枚举:Cash, Stable, Advanced
- 创建 assets 表并实现 CRUD 操作
- 支持按类别查询资产列表
- 金额精度:小数点后 2 位
数据模型:
#![allow(unused)] fn main() { pub enum Category { Cash, // 现金类 Stable, // 稳健类 Advanced, // 进阶类 } pub struct Asset { pub id: Uuid, pub name: String, pub category: Category, pub sub_category: Option<String>, pub current_value: Decimal, pub notes: Option<String>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } }
E1-S2: 新增资产条目
优先级: P0
PRD 引用: US-F1-01
描述:
作为用户,我希望能够新增一个资产条目(包含名称、类别、当前市值),以便记录我持有的资产。
验收标准:
- 提供"新增资产"按钮,点击弹出模态框/侧边栏表单
- 表单字段:名称(必填)、类别(必填,下拉选择)、子类别(可选)、市值(必填,数字输入)、备注(可选)
- 名称验证:非空,最大 100 字符
- 市值验证:非负数,自动格式化为 2 位小数
- 保存成功后关闭表单,资产列表自动刷新
- 保存失败时显示错误提示
E1-S3: 编辑资产条目
优先级: P0
PRD 引用: US-F1-02
描述:
作为用户,我希望能够编辑已有资产条目的信息,以便修正录入错误或更新资产状态。
验收标准:
- 资产列表中每个条目有"编辑"操作入口
- 点击编辑打开预填充的表单
- 表单验证规则与新增一致
- 保存时自动更新 updated_at 时间戳
- 支持取消编辑,不保存变更
E1-S4: 删除资产条目
优先级: P0
PRD 引用: US-F1-03, BR-F1-03
描述:
作为用户,我希望能够删除不再持有的资产条目,以便保持资产列表的准确性。
验收标准:
- 资产列表中每个条目有"删除"操作入口
- 点击删除弹出确认对话框:"确定删除资产「XXX」吗?此操作不可撤销。"
- 确认后从数据库删除记录
- 历史快照中的该资产数据保留(快照不可变原则)
- 删除后列表自动刷新
E1-S5: 资产列表展示
优先级: P0
PRD 引用: US-F1-04, UI-F1-01
描述:
作为用户,我希望资产条目能够按类别(现金类/稳健类/进阶类)组织展示,以便快速定位和管理。
验收标准:
- 资产列表按三个类别分组展示
- 每个类别区块显示:类别名称、资产数量、类别总市值
- 每个资产条目显示:名称、子类别(如有)、当前市值
- 类别区块支持折叠/展开
- 空类别显示"暂无资产"占位
- 页面顶部显示资产总数和总市值
界面参考:
资产管理 总计: 10 项 ¥ 500,000.00
──────────────────────────────────────────────────────────
▼ 现金类 (3 项) ¥ 100,000.00
├─ 余额宝 ¥ 50,000.00
├─ 活期存款 [银行活期] ¥ 30,000.00
└─ 定期存单 [大额存单] ¥ 20,000.00
▼ 稳健类 (2 项) ¥ 150,000.00
├─ XX债券基金 [纯债] ¥ 80,000.00
└─ YY理财产品 [银行理财] ¥ 70,000.00
▼ 进阶类 (5 项) ¥ 250,000.00
├─ 沪深300ETF [宽基指数] ¥ 100,000.00
└─ ...
E1-S6: 执行盘点操作
优先级: P0
PRD 引用: US-F1-05, UI-F1-03, FR-F1-03, FR-F1-04
描述:
作为用户,我希望能够执行一次"盘点"操作,批量更新所有资产的当前市值,并自动记录盘点时间戳。
验收标准:
- 资产管理页有"开始盘点"按钮
- 点击后进入盘点模式,页面有明显的视觉区分(如顶部提示条)
- 盘点模式下,每个资产的市值变为可编辑输入框
- 每个输入框旁显示"上次市值"作为参考
-
提供"完成盘点"按钮:
- 创建盘点快照,记录当前所有资产的状态
- 更新各资产的 current_value
- 退出盘点模式,显示成功提示
- 提供"取消盘点"按钮:放弃所有修改,退出盘点模式
- 业务规则:至少有 1 个资产才能盘点
- 业务规则:每天最多 5 次盘点(超出提示并阻止)
盘点快照数据:
#![allow(unused)] fn main() { pub struct Snapshot { pub id: Uuid, pub snapshot_date: NaiveDate, pub created_at: DateTime<Utc>, pub total_value: Decimal, pub items: Vec<SnapshotItem>, } pub struct SnapshotItem { pub asset_id: Uuid, pub asset_name: String, pub category: Category, pub sub_category: Option<String>, pub value: Decimal, } }
E1-S7: 查看盘点历史列表
优先级: P0
PRD 引用: US-F1-06, UI-F1-04
描述:
作为用户,我希望能够查看历史盘点记录列表,以便回顾资产变化轨迹。
验收标准:
- 盘点历史页以时间线形式展示所有历史快照
- 每条记录显示:盘点日期、资产总额
- 每条记录显示与上次盘点的变化:金额差(+¥1,234 或 -¥567)、百分比(+2.5%)
- 记录按时间倒序排列(最新在上)
- 空状态显示"暂无盘点记录,请先进行一次盘点"
- 支持点击记录查看详情(见 E1-S8)
E1-S8: 查看盘点快照详情
优先级: P1
PRD 引用: US-F1-07
描述:
作为用户,我希望能够查看某次历史盘点的详细快照,以便了解当时的资产状态。
验收标准:
- 点击历史记录可展开/跳转至详情视图
- 详情视图显示:盘点日期、总市值、各类别市值
- 显示该快照中所有资产条目:名称、类别、子类别、当时市值
- 提供返回按钮回到历史列表
E2: 配置方案模块
目标: 实现目标配置方案的创建、编辑和管理
E2-S1: 配置方案数据模型
优先级: P0
PRD 引用: FR-F2-01, FR-F2-02
描述:
作为开发者,我需要定义配置方案的数据模型和数据库表结构。
验收标准:
- 定义 AllocationPlan 结构体
- 定义 Allocation 结构体(配置项)
- 创建数据库表并实现 CRUD 操作
- 支持查询当前激活方案
- 应用首次启动时自动创建默认"平衡型"方案并激活
数据模型:
#![allow(unused)] fn main() { pub struct AllocationPlan { pub id: Uuid, pub name: String, pub description: Option<String>, pub is_active: bool, pub allocations: Vec<Allocation>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } pub struct Allocation { pub category: Category, pub target_percentage: Decimal, // 0-100 pub min_percentage: Option<Decimal>, pub max_percentage: Option<Decimal>, } }
E2-S2: 创建配置方案
优先级: P0
PRD 引用: US-F2-01, UI-F2-02
描述:
作为用户,我希望能够创建一个配置方案,定义各资产类别的目标占比,以便作为资产配置的参照标准。
验收标准:
- 提供"新建方案"按钮,打开方案编辑器
- 编辑器包含:方案名称(必填)、描述(可选)
- 提供三个类别的占比设置(滑块或数字输入)
- 实时显示占比总和,必须 = 100%
- 总和不等于 100% 时禁用保存按钮并提示
- 可选:饼图预览目标配置
- 保存后方案出现在方案列表中
E2-S3: 编辑配置方案
优先级: P0
PRD 引用: US-F2-02
描述:
作为用户,我希望能够编辑已有的配置方案,以便根据投资策略变化调整目标配置。
验收标准:
- 方案列表中每个方案有"编辑"操作入口
- 点击编辑打开预填充的方案编辑器
- 编辑规则与创建一致
- 保存时更新 updated_at 时间戳
E2-S4: 删除配置方案
优先级: P1
PRD 引用: US-F2-03, BR-F2-04, BR-F2-05
描述:
作为用户,我希望能够删除不再使用的配置方案,以便保持方案列表简洁。
验收标准:
- 方案列表中每个方案有"删除"操作入口
- 当前激活方案不可删除,删除按钮置灰并提示"请先切换激活方案"
- 系统仅剩 1 个方案时不可删除,提示"至少保留一个方案"
- 点击删除弹出确认对话框
- 确认后删除方案
E2-S5: 激活配置方案
优先级: P0
PRD 引用: US-F2-04, BR-F2-03
描述:
作为用户,我希望能够将某个方案设为"当前激活",用于计算配置偏离度。
验收标准:
- 方案列表中每个非激活方案有"设为当前"操作入口
- 当前激活方案有明显视觉标识(如徽章、高亮背景)
-
点击"设为当前"后:
- 该方案变为激活状态
- 原激活方案变为非激活
- 资产视图页的偏离度重新计算
- 系统始终有且仅有一个激活方案
E2-S6: 预设方案模板
优先级: P1
PRD 引用: US-F2-06, UI-F2-03
描述:
作为用户,我希望系统提供预设的配置方案模板作为起点,以便快速开始使用。
验收标准:
- 新建方案时提供"从模板创建"选项
-
提供 3 套预设模板:
- 保守型:Cash 30%, Stable 50%, Advanced 20%
- 平衡型:Cash 20%, Stable 40%, Advanced 40%
- 进取型:Cash 10%, Stable 20%, Advanced 70%
- 选择模板后自动填充占比,用户可进一步调整
- 应用首次启动自动创建"平衡型"方案
E3: 资产视图模块
目标: 展示资产全貌,可视化配置对比和偏离情况
E3-S1: 资产总览卡片
优先级: P0
PRD 引用: US-F3-01, UI-F3-01
描述:
作为用户,我希望在首页看到资产总额和各类别的分布情况,以便快速了解资产全貌。
验收标准:
- 首页顶部显示资产总额卡片,突出显示总市值
- 显示上次盘点日期
- 显示各类别的市值和占比
- 无资产时显示引导文案"暂无资产,点击添加您的第一个资产"
E3-S2: 配置对比图表
优先级: P0
PRD 引用: US-F3-02, UI-F3-03
描述:
作为用户,我希望能够直观对比当前配置与目标配置的差异,以便判断是否需要调整。
验收标准:
- 首页展示双饼图:左侧"当前配置",右侧"目标配置"
- 同类别使用相同颜色
- 鼠标悬停显示具体金额和百分比
- 目标配置来自当前激活方案
- 无激活方案时提示"请先创建配置方案"
E3-S3: 偏离状态列表
优先级: P0
PRD 引用: US-F3-03, US-F3-04, FR-F3-01, FR-F3-02, UI-F3-02
描述:
作为用户,我希望当某类别偏离目标超过阈值时,系统能够高亮提示,并看到各类别的偏离百分比。
验收标准:
- 首页显示类别配置对比表格
- 表格列:类别、当前占比、目标占比、偏离度、状态
- 偏离度计算:当前占比 - 目标占比
-
状态判定(默认容忍度 5%):
- |偏离度| ≤ 5%:正常(绿色 ✓)
- |偏离度| > 5%:轻度偏离(黄色 ⚠️)
- |偏离度| > 10%:严重偏离(红色 ❗)
- 偏离度显示正负符号:+5%(超配)、-3%(低配)
偏离计算逻辑:
#![allow(unused)] fn main() { fn calculate_deviation(current: Decimal, target: Decimal) -> DeviationResult { let deviation = current - target; let abs_deviation = deviation.abs(); let status = if abs_deviation <= Decimal::new(5, 0) { DeviationStatus::Normal } else if abs_deviation <= Decimal::new(10, 0) { DeviationStatus::Mild } else { DeviationStatus::Severe }; let direction = if deviation > Decimal::ZERO { DeviationDirection::Overweight } else { DeviationDirection::Underweight }; DeviationResult { deviation, status, direction } } }
E3-S4: 无数据状态处理
优先级: P1
描述:
作为用户,当我没有资产或没有配置方案时,我希望看到友好的引导提示。
验收标准:
- 无资产时:显示空状态插图 + "开始添加资产"按钮
- 无配置方案时:显示"创建配置方案"按钮
- 无盘点记录时:偏离度显示"--",提示"请先完成一次盘点"
E3-S5: 再平衡方向提示
优先级: P2
PRD 引用: US-F3-05
描述:
作为用户,我希望能够一键查看再平衡所需的资金调整方向,以便规划调仓。
验收标准:
- 偏离状态列表增加"调整方向"列
- 超配类别显示"↓ 减少"
- 低配类别显示"↑ 增加"
- 正常类别显示"- 维持"
- 可选:显示建议调整金额(粗略估算)
E4: 收益分析模块
目标: 基于盘点快照计算和展示收益情况
E4-S1: 周期收益计算
优先级: P0
PRD 引用: US-F4-01, US-F4-02, FR-F4-01, FR-F4-02
描述:
作为用户,我希望看到选定时间段内的资产总收益(金额和百分比),并能按季度/年度查看。
验收标准:
- 收益分析页顶部提供周期选择器
- 支持选择:季度(Q1-Q4)、年度
- 支持选择年份
- 显示本期收益金额(绝对值)
- 显示本期收益率(百分比)
- 显示期初/期末总市值
- 至少需要 2 个快照才能计算,否则显示"数据不足"
收益计算:
#![allow(unused)] fn main() { fn calculate_period_return(start_snapshot: &Snapshot, end_snapshot: &Snapshot) -> ReturnResult { let absolute_return = end_snapshot.total_value - start_snapshot.total_value; let return_rate = if start_snapshot.total_value > Decimal::ZERO { (absolute_return / start_snapshot.total_value) * Decimal::new(100, 0) } else { Decimal::ZERO // 或返回 N/A }; ReturnResult { period: (start_snapshot.snapshot_date, end_snapshot.snapshot_date), start_value: start_snapshot.total_value, end_value: end_snapshot.total_value, absolute_return, return_rate, } } }
E4-S2: 收益归因分析
优先级: P0
PRD 引用: US-F4-03, FR-F4-04, UI-F4-01, UI-F4-02
描述:
作为用户,我希望看到各资产类别的收益贡献,以便了解哪些类别在产生收益。
验收标准:
- 收益分析页显示收益归因表格
- 表格列:类别、期初市值、期末市值、收益金额、收益率、贡献度
- 贡献度 = 该类别收益 / 总收益 × 100%
- 正收益显示绿色,负收益显示红色
- 可选:横向条形图可视化各类别收益贡献
E4-S3: 资产趋势图
优先级: P1
PRD 引用: US-F4-04, UI-F4-03
描述:
作为用户,我希望看到资产总额的历史变化趋势图,以便直观感受资产增长轨迹。
验收标准:
- 收益分析页底部显示折线图
- X 轴:盘点日期
- Y 轴:资产总额
- 数据点来自历史盘点快照
- 鼠标悬停显示具体日期和金额
- 至少 2 个数据点才显示趋势线
- 可选:支持时间范围筛选
E4-S4: 自定义周期对比
优先级: P2
PRD 引用: US-F4-05
描述:
作为用户,我希望能够选择任意两个盘点时间点进行收益对比,以便灵活分析。
验收标准:
- 提供"自定义对比"入口
- 可选择起始快照和结束快照
- 计算并显示两个时间点之间的收益
- 复用周期收益的展示组件
E4-S5: 无数据状态处理
优先级: P1
描述:
作为用户,当盘点数据不足时,我希望看到友好的提示。
验收标准:
- 无快照时:显示"暂无盘点数据,请先进行资产盘点"
- 仅 1 个快照时:显示"需要至少 2 次盘点才能计算收益"
- 选定周期内无快照时:显示"该周期内暂无盘点记录"
开发建议顺序
迭代 1:基础骨架(预计 3-5 天)
| Story | 说明 |
|---|---|
| E0-S1 | 项目初始化 |
| E0-S2 | 数据持久化层 |
| E0-S3 | 应用布局框架 |
| E0-S4 | 全局状态管理 |
迭代 2:资产管理核心(预计 5-7 天)
| Story | 说明 |
|---|---|
| E1-S1 | 资产数据模型 |
| E1-S2 | 新增资产 |
| E1-S3 | 编辑资产 |
| E1-S4 | 删除资产 |
| E1-S5 | 资产列表展示 |
迭代 3:盘点功能(预计 3-5 天)
| Story | 说明 |
|---|---|
| E1-S6 | 执行盘点操作 |
| E1-S7 | 盘点历史列表 |
| E1-S8 | 盘点快照详情 |
迭代 4:配置方案(预计 3-5 天)
| Story | 说明 |
|---|---|
| E2-S1 | 配置方案数据模型 |
| E2-S2 | 创建配置方案 |
| E2-S3 | 编辑配置方案 |
| E2-S5 | 激活配置方案 |
迭代 5:资产视图(预计 3-5 天)
| Story | 说明 |
|---|---|
| E3-S1 | 资产总览卡片 |
| E3-S2 | 配置对比图表 |
| E3-S3 | 偏离状态列表 |
迭代 6:收益分析(预计 3-5 天)
| Story | 说明 |
|---|---|
| E4-S1 | 周期收益计算 |
| E4-S2 | 收益归因分析 |
| E4-S3 | 资产趋势图 |
迭代 7:完善优化(预计 2-3 天)
| Story | 说明 |
|---|---|
| E2-S4 | 删除配置方案 |
| E2-S6 | 预设方案模板 |
| E3-S4 | 无数据状态处理 |
| E4-S5 | 无数据状态处理 |
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-12-20 | PM Agent | 初始版本 |
This document breaks down the PRD into implementable Epics and Stories.
Next: Architecture design will define the technical implementation approach.
Epics and Stories (Final): asset-light MVP
版本: 2.0.0 (Final)
日期: 2025-12-20
关联文档:
概览
本文档是基于技术架构设计的最终版 Epics 和 Stories,包含具体的技术实现任务。
Epic 总览
| Epic ID | 名称 | Stories | 任务数 | 预计工时 |
|---|---|---|---|---|
| E0 | 项目基础设施 | 4 | 16 | 3-4 天 |
| E1 | 资产盘点模块 | 8 | 28 | 5-7 天 |
| E2 | 配置方案模块 | 6 | 20 | 3-5 天 |
| E3 | 资产视图模块 | 5 | 18 | 3-4 天 |
| E4 | 收益分析模块 | 5 | 16 | 3-4 天 |
| 合计 | - | 28 | 98 | 17-24 天 |
E0: 项目基础设施
E0-S1: 项目初始化
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T0-1-1 | 创建 Cargo 项目 | Cargo.toml |
| T0-1-2 | 配置 Dioxus Desktop 依赖 | Cargo.toml |
| T0-1-3 | 创建 Dioxus.toml 配置 | Dioxus.toml |
| T0-1-4 | 创建目录结构 | src/* |
| T0-1-5 | 创建 main.rs 入口 | src/main.rs |
| T0-1-6 | 配置 rustfmt.toml | rustfmt.toml |
代码模板
Cargo.toml:
[package]
name = "asset-light"
version = "0.1.0"
edition = "2021"
[dependencies]
dioxus = { version = "0.5", features = ["desktop", "router"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rust_decimal = { version = "1.0", features = ["serde"] }
dirs = "5.0"
thiserror = "1.0"
验收标准:
-
cargo build编译成功 -
cargo run启动空白窗口 - 目录结构符合架构设计
E0-S2: 数据持久化层
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T0-2-1 | 创建 Database 连接管理 | src/db/connection.rs |
| T0-2-2 | 实现数据库路径管理 | src/db/connection.rs |
| T0-2-3 | 创建迁移脚本 001_create_assets | migrations/001_create_assets.sql |
| T0-2-4 | 创建迁移脚本 002_create_snapshots | migrations/002_create_snapshots.sql |
| T0-2-5 | 创建迁移脚本 003_create_plans | migrations/003_create_plans.sql |
| T0-2-6 | 实现迁移执行逻辑 | src/db/migrations.rs |
SQL 迁移脚本
001_create_assets.sql:
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('Cash', 'Stable', 'Advanced')),
sub_category TEXT,
current_value TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category);
002_create_snapshots.sql:
CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
snapshot_date TEXT NOT NULL,
created_at TEXT NOT NULL,
total_value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON snapshots(snapshot_date);
CREATE TABLE IF NOT EXISTS snapshot_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
asset_name TEXT NOT NULL,
category TEXT NOT NULL,
sub_category TEXT,
value TEXT NOT NULL,
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_snapshot_items_snapshot ON snapshot_items(snapshot_id);
003_create_plans.sql:
CREATE TABLE IF NOT EXISTS allocation_plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
is_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS allocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
category TEXT NOT NULL,
target_percentage TEXT NOT NULL,
min_percentage TEXT,
max_percentage TEXT,
FOREIGN KEY (plan_id) REFERENCES allocation_plans(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_allocations_plan ON allocations(plan_id);
验收标准:
-
数据库文件创建在
~/Library/Application Support/asset-light/data.db - 迁移执行成功
- 表结构正确
E0-S3: 应用布局框架
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T0-3-1 | 定义路由枚举 | src/router.rs |
| T0-3-2 | 创建 Layout 组件 | src/components/layout/mod.rs |
| T0-3-3 | 创建 Sidebar 组件 | src/components/layout/sidebar.rs |
| T0-3-4 | 创建 NavItem 组件 | src/components/layout/sidebar.rs |
| T0-3-5 | 创建 PageContainer 组件 | src/components/layout/page_container.rs |
| T0-3-6 | 创建 5 个页面占位组件 | src/pages/*.rs |
组件结构
src/components/layout/
├── mod.rs // pub mod sidebar; pub mod page_container;
├── sidebar.rs // Sidebar, NavItem
└── page_container.rs
src/pages/
├── mod.rs
├── home.rs // HomePage
├── assets.rs // AssetsPage
├── history.rs // HistoryPage
├── plans.rs // PlansPage
└── analysis.rs // AnalysisPage
验收标准:
- 侧边栏显示 5 个导航项
- 点击导航项切换页面
- 当前页面导航项高亮
E0-S4: 全局状态管理
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T0-4-1 | 定义 AppState 结构体 | src/state/app_state.rs |
| T0-4-2 | 实现 provide_app_state | src/state/app_state.rs |
| T0-4-3 | 实现 use_app_state Hook | src/state/app_state.rs |
| T0-4-4 | 在 App 组件中提供状态 | src/app.rs |
状态结构
#![allow(unused)] fn main() { // src/state/app_state.rs #[derive(Clone, Default)] pub struct AppState { pub assets: Vec<Asset>, pub snapshots: Vec<Snapshot>, pub plans: Vec<AllocationPlan>, pub active_plan: Option<AllocationPlan>, pub is_inventory_mode: bool, pub loading: bool, pub error: Option<String>, } }
验收标准:
- 全局状态在所有页面可访问
- 状态变更触发 UI 更新
E1: 资产盘点模块
E1-S1: 资产条目数据模型
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T1-1-1 | 定义 Category 枚举 | src/models/category.rs |
| T1-1-2 | 定义 Asset 结构体 | src/models/asset.rs |
| T1-1-3 | 实现 Asset::new() | src/models/asset.rs |
| T1-1-4 | 创建 AssetRepository | src/db/asset_repo.rs |
| T1-1-5 | 实现 CRUD 方法 | src/db/asset_repo.rs |
Repository 接口
#![allow(unused)] fn main() { // src/db/asset_repo.rs impl AssetRepository { pub fn insert(&self, asset: &Asset) -> Result<()>; pub fn update(&self, asset: &Asset) -> Result<()>; pub fn delete(&self, id: Uuid) -> Result<()>; pub fn find_by_id(&self, id: Uuid) -> Result<Option<Asset>>; pub fn find_all(&self) -> Result<Vec<Asset>>; pub fn find_by_category(&self, category: Category) -> Result<Vec<Asset>>; } }
验收标准:
- 资产可增删改查
- 按类别查询正常
E1-S2: 新增资产条目
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T1-2-1 | 创建 Modal 通用组件 | src/components/common/modal.rs |
| T1-2-2 | 创建 Input 通用组件 | src/components/common/input.rs |
| T1-2-3 | 创建 Select 通用组件 | src/components/common/select.rs |
| T1-2-4 | 创建 Button 通用组件 | src/components/common/button.rs |
| T1-2-5 | 创建 AssetForm 组件 | src/components/asset/asset_form.rs |
| T1-2-6 | 创建 AssetService | src/services/asset_service.rs |
| T1-2-7 | 实现表单验证逻辑 | src/components/asset/asset_form.rs |
组件 Props
#![allow(unused)] fn main() { #[component] pub fn AssetForm( asset: Option<Asset>, // None = 新增, Some = 编辑 on_save: EventHandler<Asset>, on_cancel: EventHandler<()>, ) -> Element }
验收标准:
- 点击新增按钮弹出表单
- 表单验证生效
- 保存成功关闭表单
E1-S3: 编辑资产条目
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T1-3-1 | AssetItem 添加编辑按钮 | src/components/asset/asset_item.rs |
| T1-3-2 | 复用 AssetForm 组件 | src/pages/assets.rs |
| T1-3-3 | 实现编辑状态管理 | src/pages/assets.rs |
验收标准:
- 点击编辑打开预填充表单
- 保存更新资产信息
E1-S4: 删除资产条目
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T1-4-1 | 创建 ConfirmDialog 组件 | src/components/common/confirm_dialog.rs |
| T1-4-2 | AssetItem 添加删除按钮 | src/components/asset/asset_item.rs |
| T1-4-3 | 实现删除确认流程 | src/pages/assets.rs |
验收标准:
- 点击删除弹出确认
- 确认后删除成功
E1-S5: 资产列表展示
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T1-5-1 | 创建 AssetList 组件 | src/components/asset/asset_list.rs |
| T1-5-2 | 创建 CategoryGroup 组件 | src/components/asset/category_group.rs |
| T1-5-3 | 创建 AssetItem 组件 | src/components/asset/asset_item.rs |
| T1-5-4 | 实现折叠/展开功能 | src/components/asset/category_group.rs |
| T1-5-5 | 实现汇总计算 | src/pages/assets.rs |
组件层次
AssetList
├── CategoryGroup (Cash)
│ ├── AssetItem
│ └── AssetItem
├── CategoryGroup (Stable)
│ └── AssetItem
└── CategoryGroup (Advanced)
└── AssetItem
验收标准:
- 资产按类别分组显示
- 显示类别汇总
- 折叠展开正常
E1-S6: 执行盘点操作
预计工时: 1.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T1-6-1 | 定义 Snapshot 模型 | src/models/snapshot.rs |
| T1-6-2 | 创建 SnapshotRepository | src/db/snapshot_repo.rs |
| T1-6-3 | 创建 SnapshotService | src/services/snapshot_service.rs |
| T1-6-4 | 创建 InventoryMode 组件 | src/components/snapshot/inventory_mode.rs |
| T1-6-5 | 创建 InventoryItem 组件 | src/components/snapshot/inventory_item.rs |
| T1-6-6 | 实现盘点次数限制检查 | src/services/snapshot_service.rs |
| T1-6-7 | 实现盘点保存逻辑 | src/services/snapshot_service.rs |
盘点流程
开始盘点 → 进入盘点模式 → 编辑各资产市值 → 完成盘点 → 创建快照 → 更新资产
验收标准:
- 盘点模式 UI 区分明显
- 可编辑各资产市值
- 快照正确保存
- 每日限制 5 次
E1-S7: 查看盘点历史列表
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T1-7-1 | 创建 SnapshotTimeline 组件 | src/components/snapshot/snapshot_timeline.rs |
| T1-7-2 | 创建 SnapshotCard 组件 | src/components/snapshot/snapshot_card.rs |
| T1-7-3 | 实现变化计算 | src/components/snapshot/snapshot_card.rs |
验收标准:
- 时间线展示历史
- 显示变化金额和百分比
E1-S8: 查看盘点快照详情
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T1-8-1 | 创建 SnapshotDetail 组件 | src/components/snapshot/snapshot_detail.rs |
| T1-8-2 | 实现详情弹窗或展开 | src/pages/history.rs |
验收标准:
- 可查看快照详细资产列表
E2: 配置方案模块
E2-S1: 配置方案数据模型
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T2-1-1 | 定义 AllocationPlan 结构体 | src/models/plan.rs |
| T2-1-2 | 定义 Allocation 结构体 | src/models/plan.rs |
| T2-1-3 | 创建 PlanRepository | src/db/plan_repo.rs |
| T2-1-4 | 实现默认方案创建 | src/db/plan_repo.rs |
验收标准:
- 首次启动创建默认方案
- 方案 CRUD 正常
E2-S2: 创建配置方案
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T2-2-1 | 创建 PlanEditor 组件 | src/components/plan/plan_editor.rs |
| T2-2-2 | 创建 AllocationSlider 组件 | src/components/plan/allocation_slider.rs |
| T2-2-3 | 实现 100% 验证逻辑 | src/components/plan/plan_editor.rs |
| T2-2-4 | 创建 PlanService | src/services/plan_service.rs |
验收标准:
- 可创建新方案
- 占比总和必须 100%
E2-S3: 编辑配置方案
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T2-3-1 | PlanCard 添加编辑按钮 | src/components/plan/plan_card.rs |
| T2-3-2 | 复用 PlanEditor | src/pages/plans.rs |
验收标准:
- 可编辑现有方案
E2-S4: 删除配置方案
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T2-4-1 | PlanCard 添加删除按钮 | src/components/plan/plan_card.rs |
| T2-4-2 | 实现删除限制检查 | src/services/plan_service.rs |
验收标准:
- 激活方案不可删除
- 最后一个方案不可删除
E2-S5: 激活配置方案
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T2-5-1 | PlanCard 添加激活按钮 | src/components/plan/plan_card.rs |
| T2-5-2 | 实现激活切换逻辑 | src/services/plan_service.rs |
| T2-5-3 | 更新全局状态 active_plan | src/state/app_state.rs |
验收标准:
- 可切换激活方案
- 始终只有一个激活
E2-S6: 预设方案模板
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T2-6-1 | 定义模板常量 | src/models/plan.rs |
| T2-6-2 | PlanEditor 添加模板选择 | src/components/plan/plan_editor.rs |
模板定义:
#![allow(unused)] fn main() { impl AllocationPlan { pub fn template_conservative() -> Self { /* Cash 30, Stable 50, Advanced 20 */ } pub fn template_balanced() -> Self { /* Cash 20, Stable 40, Advanced 40 */ } pub fn template_aggressive() -> Self { /* Cash 10, Stable 20, Advanced 70 */ } } }
验收标准:
- 提供 3 个模板
- 选择模板自动填充
E3: 资产视图模块
E3-S1: 资产总览卡片
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T3-1-1 | 创建 TotalCard 组件 | src/components/dashboard/total_card.rs |
| T3-1-2 | 创建 StatCard 通用组件 | src/components/common/stat_card.rs |
| T3-1-3 | 实现汇总计算 | src/pages/home.rs |
验收标准:
- 显示总资产
- 显示上次盘点日期
E3-S2: 配置对比图表
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T3-2-1 | 创建 PieChart 组件 | src/components/dashboard/pie_chart.rs |
| T3-2-2 | 创建 PieChartPair 组件 | src/components/dashboard/pie_chart_pair.rs |
| T3-2-3 | 实现 SVG 饼图渲染 | src/components/dashboard/pie_chart.rs |
| T3-2-4 | 实现悬停提示 | src/components/dashboard/pie_chart.rs |
验收标准:
- 双饼图并列显示
- 类别颜色一致
E3-S3: 偏离状态列表
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T3-3-1 | 创建 DeviationTable 组件 | src/components/dashboard/deviation_table.rs |
| T3-3-2 | 创建 StatusBadge 组件 | src/components/common/status_badge.rs |
| T3-3-3 | 创建 AnalysisService | src/services/analysis_service.rs |
| T3-3-4 | 实现 calculate_deviation | src/services/analysis_service.rs |
验收标准:
- 显示偏离度表格
- 状态颜色正确
E3-S4: 无数据状态处理
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T3-4-1 | 创建 EmptyState 组件 | src/components/common/empty_state.rs |
| T3-4-2 | 首页添加空状态处理 | src/pages/home.rs |
验收标准:
- 无数据时显示引导
E3-S5: 再平衡方向提示
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T3-5-1 | DeviationTable 添加方向列 | src/components/dashboard/deviation_table.rs |
| T3-5-2 | 实现方向箭头显示 | src/components/dashboard/deviation_table.rs |
验收标准:
- 显示调整方向
E4: 收益分析模块
E4-S1: 周期收益计算
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T4-1-1 | 创建 PeriodSelector 组件 | src/components/analysis/period_selector.rs |
| T4-1-2 | 创建 ReturnCard 组件 | src/components/analysis/return_card.rs |
| T4-1-3 | 实现 calculate_return | src/services/analysis_service.rs |
| T4-1-4 | 实现周期筛选逻辑 | src/pages/analysis.rs |
验收标准:
- 可选择季度/年度
- 正确计算收益
E4-S2: 收益归因分析
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T4-2-1 | 创建 AttributionTable 组件 | src/components/analysis/attribution_table.rs |
| T4-2-2 | 实现类别收益计算 | src/services/analysis_service.rs |
| T4-2-3 | 实现贡献度计算 | src/services/analysis_service.rs |
验收标准:
- 显示各类别收益
- 正负收益颜色区分
E4-S3: 资产趋势图
预计工时: 1 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T4-3-1 | 创建 TrendChart 组件 | src/components/analysis/trend_chart.rs |
| T4-3-2 | 实现 SVG 折线图 | src/components/analysis/trend_chart.rs |
| T4-3-3 | 实现悬停数据点提示 | src/components/analysis/trend_chart.rs |
验收标准:
- 折线图正确渲染
- 悬停显示详情
E4-S4: 自定义周期对比
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T4-4-1 | PeriodSelector 添加自定义入口 | src/components/analysis/period_selector.rs |
| T4-4-2 | 创建快照选择器 | src/components/analysis/snapshot_picker.rs |
验收标准:
- 可选择任意两个快照对比
E4-S5: 无数据状态处理
预计工时: 0.5 天
技术任务
| 任务 ID | 任务 | 文件/路径 |
|---|---|---|
| T4-5-1 | 分析页添加空状态处理 | src/pages/analysis.rs |
验收标准:
- 数据不足时友好提示
Sprint 规划建议
Sprint 1: 基础设施 (3-4 天)
- E0-S1: 项目初始化
- E0-S2: 数据持久化层
- E0-S3: 应用布局框架
- E0-S4: 全局状态管理
Sprint 2: 资产管理 (5-7 天)
- E1-S1: 资产数据模型
- E1-S2: 新增资产
- E1-S3: 编辑资产
- E1-S4: 删除资产
- E1-S5: 资产列表展示
Sprint 3: 盘点功能 (3-4 天)
- E1-S6: 执行盘点
- E1-S7: 盘点历史
- E1-S8: 快照详情
Sprint 4: 配置方案 (3-4 天)
- E2-S1 ~ E2-S6
Sprint 5: 仪表盘 (3-4 天)
- E3-S1 ~ E3-S5
Sprint 6: 收益分析 (3-4 天)
- E4-S1 ~ E4-S5
文件清单
需创建的文件 (共约 45 个)
src/models/ (5 个)
- mod.rs, asset.rs, snapshot.rs, plan.rs, category.rs
src/db/ (5 个)
- mod.rs, connection.rs, migrations.rs, asset_repo.rs, snapshot_repo.rs, plan_repo.rs
src/services/ (5 个)
- mod.rs, asset_service.rs, snapshot_service.rs, plan_service.rs, analysis_service.rs
src/state/ (2 个)
- mod.rs, app_state.rs
src/components/common/ (8 个)
- mod.rs, button.rs, input.rs, select.rs, modal.rs, card.rs, confirm_dialog.rs, empty_state.rs, status_badge.rs, stat_card.rs
src/components/layout/ (3 个)
- mod.rs, sidebar.rs, page_container.rs
src/components/asset/ (5 个)
- mod.rs, asset_list.rs, asset_form.rs, asset_item.rs, category_group.rs
src/components/snapshot/ (5 个)
- mod.rs, inventory_mode.rs, inventory_item.rs, snapshot_timeline.rs, snapshot_card.rs, snapshot_detail.rs
src/components/plan/ (4 个)
- mod.rs, plan_list.rs, plan_card.rs, plan_editor.rs, allocation_slider.rs
src/components/dashboard/ (4 个)
- mod.rs, total_card.rs, pie_chart.rs, pie_chart_pair.rs, deviation_table.rs
src/components/analysis/ (5 个)
- mod.rs, period_selector.rs, return_card.rs, attribution_table.rs, trend_chart.rs, snapshot_picker.rs
src/pages/ (6 个)
- mod.rs, home.rs, assets.rs, history.rs, plans.rs, analysis.rs
migrations/ (3 个)
- 001_create_assets.sql, 002_create_snapshots.sql, 003_create_plans.sql
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-12-20 | PM Agent | 初始版本 |
| 2.0.0 | 2025-12-20 | PM Agent | 基于架构添加技术任务 |
Implementation Readiness Report: asset-light
日期: 2025-12-20
状态: ✅ READY FOR IMPLEMENTATION
1. 执行摘要
准备状态总览
| 维度 | 完成度 | 状态 |
|---|---|---|
| 文档完整性 | 7/7 | ✅ |
| 技术准备 | 6/6 | ✅ |
| 任务就绪 | 6/6 | ✅ |
| 质量保障 | 4/4 | ✅ |
| 总体 | 23/23 | ✅ READY |
结论
asset-light 项目已完成所有规划和设计工作,具备开始实施的条件。
2. 已完成的准备工作
2.1 产品规划文档
| 文档 | 文件 | 内容 |
|---|---|---|
| Product Brief | product-brief-asset-light-2025-12-20.md | 产品愿景、用户痛点、MVP 范围 |
| PRD | prd-asset-light-2025-12-20.md | 详细功能需求、数据模型、业务规则 |
| UI Design | ui-design-asset-light-2025-12-20.md | 视觉风格、页面设计、组件库 |
2.2 技术设计文档
| 文档 | 文件 | 内容 |
|---|---|---|
| Architecture | architecture-asset-light-2025-12-20.md | 技术栈、项目结构、数据模型、服务层 |
| Test Design | test-design-asset-light-2025-12-20.md | 测试策略、测试用例、性能指标 |
2.3 开发计划文档
| 文档 | 文件 | 内容 |
|---|---|---|
| Epics & Stories | epics-and-stories-asset-light-2025-12-20.md | 功能拆分、用户故事 |
| Epics & Stories (Final) | epics-and-stories-final-2025-12-20.md | 技术任务分解、Sprint 规划 |
3. 技术栈确认
3.1 核心依赖
[dependencies]
dioxus = { version = "0.5", features = ["desktop", "router"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rust_decimal = { version = "1.0", features = ["serde"] }
dirs = "5.0"
thiserror = "1.0"
3.2 开发环境要求
| 项目 | 要求 |
|---|---|
| Rust | 1.75+ |
| macOS | 12.0+ |
| IDE | 推荐 VS Code + rust-analyzer |
4. Sprint 规划
4.1 Sprint 概览
| Sprint | 内容 | 预计时间 | Story 数 |
|---|---|---|---|
| Sprint 1 | 基础设施 | 3-4 天 | 4 |
| Sprint 2 | 资产管理 | 5-7 天 | 5 |
| Sprint 3 | 盘点功能 | 3-4 天 | 3 |
| Sprint 4 | 配置方案 | 3-4 天 | 6 |
| Sprint 5 | 仪表盘 | 3-4 天 | 5 |
| Sprint 6 | 收益分析 | 3-4 天 | 5 |
总计: 17-24 天
4.2 Sprint 1 详细任务
目标: 搭建项目骨架,建立开发基础
| Story | 任务 | 预计时间 |
|---|---|---|
| E0-S1 | 项目初始化 | 0.5 天 |
| E0-S2 | 数据持久化层 | 1 天 |
| E0-S3 | 应用布局框架 | 1 天 |
| E0-S4 | 全局状态管理 | 0.5 天 |
Sprint 1 产出:
- 可编译运行的 Dioxus 桌面应用
- SQLite 数据库初始化和迁移
- 侧边栏导航 + 5 个页面占位
- 全局状态 Context
5. 风险评估
5.1 技术风险
| 风险 | 可能性 | 影响 | 缓解措施 |
|---|---|---|---|
| Dioxus 学习曲线 | 中 | 中 | 参考官方文档和示例 |
| 图表渲染复杂度 | 低 | 低 | 使用 SVG 手动绘制或简化 |
| 数据迁移兼容 | 低 | 中 | 版本化迁移脚本 |
5.2 进度风险
| 风险 | 可能性 | 影响 | 缓解措施 |
|---|---|---|---|
| 功能蔓延 | 中 | 高 | 严格遵守 MVP 范围 |
| 估算偏差 | 中 | 中 | 迭代调整,P2 可延后 |
6. 开始实施清单
6.1 环境准备
# 1. 确认 Rust 版本
rustc --version # 需要 1.75+
# 2. 安装 Dioxus CLI
cargo install dioxus-cli
# 3. 创建项目
cargo new asset-light
cd asset-light
# 4. 初始化 Dioxus
dx init
6.2 首个 Commit 内容
-
Cargo.toml- 依赖配置 -
Dioxus.toml- Dioxus 配置 -
src/main.rs- 应用入口 -
src/app.rs- 根组件 -
.gitignore- Git 忽略配置 -
README.md- 项目说明
6.3 目录结构创建
mkdir -p src/{components/{layout,common,asset,snapshot,plan,dashboard,analysis},pages,models,services,state,db,utils}
mkdir -p migrations
mkdir -p assets/{icons,styles}
7. 验收标准
7.1 MVP 完成标准
| 标准 | 验收条件 |
|---|---|
| 资产盘点 | 3 分钟内完成 10 个资产盘点 |
| 配置管理 | 创建、编辑、切换 2+ 套方案 |
| 偏离监控 | 清晰显示配置偏离情况 |
| 收益分析 | 查看季度/年度收益率 |
| 性能 | 启动 < 2s,切换 < 200ms |
| 稳定性 | 无崩溃、无数据丢失 |
7.2 代码质量标准
| 标准 | 要求 |
|---|---|
| Clippy | 无警告 |
| 测试覆盖率 | ≥ 80% |
| 代码风格 | rustfmt 格式化 |
| 文档 | 公共 API 有注释 |
8. 下一步行动
立即执行
- 创建 Git 仓库
- 执行 Sprint 1 - Story E0-S1: 项目初始化
- 配置 CI/CD(可选)
Sprint 1 目标
完成基础设施搭建,应用能够:
- ✅ 编译并启动窗口
- ✅ 显示侧边栏和页面占位
- ✅ 连接 SQLite 数据库
- ✅ 全局状态可用
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-12-20 | Architect | 初始版本 |
🎉 Congratulations! asset-light is READY FOR IMPLEMENTATION!
BMM Workflow Status(渲染版)
mdBook 只能直接收录 Markdown。
本页用于把docs/bmm-workflow-status.yaml以代码块形式渲染到文档站点中,方便浏览与检索。
原始文件
docs/bmm-workflow-status.yaml
内容
# BMM Workflow Status - asset-light
# Generated by workflow-init
# Track progress through BMM methodology phases
# STATUS DEFINITIONS:
# ==================
# Initial Status (before completion):
# - required: Must be completed to progress
# - optional: Can be completed but not required
# - recommended: Strongly suggested but not required
# - conditional: Required only if certain conditions met
#
# Completion Status:
# - {file-path}: File created/found (e.g., "docs/product-brief.md")
# - skipped: Optional/conditional workflow that was skipped
generated: "2025-12-20"
project: "asset-light"
project_type: "desktop-app"
selected_track: "bmad-method"
field_type: "greenfield"
workflow_path: "method-greenfield.yaml"
# User selections from workflow-init
discovery_selections:
brainstorm: false
research: false
product_brief: true
# Project context captured during init
project_context:
description: "基于 Dioxus 技术栈的个人资产管理桌面应用"
tech_stack: "Dioxus (Rust)"
target_user: "个人用户"
core_features:
- "资产盘点(新增/更新资产条目)"
- "资产视图"
- "资产评估及建议"
asset_categories:
- name: "现金类"
examples: ["余额宝", "活期存款", "定期存单", "借出款项"]
- name: "稳健类"
examples: ["债券基金"]
- name: "进阶类"
examples: ["股票基金", "混合基金", "ETF"]
workflow_status:
# Phase 0: Discovery (Optional)
phase_0_discovery:
- id: "brainstorm-project"
status: "skipped"
agent: "analyst"
command: "brainstorm-project"
- id: "research"
status: "skipped"
agent: "analyst"
command: "research"
- id: "product-brief"
status: "docs/product-brief-asset-light-2025-12-20.md"
agent: "analyst"
command: "product-brief"
output: "docs/product-brief-asset-light-2025-12-20.md"
# Phase 1: Planning
phase_1_planning:
- id: "prd"
status: "docs/prd-asset-light-2025-12-20.md"
agent: "pm"
command: "prd"
output: "docs/prd-asset-light-2025-12-20.md"
- id: "validate-prd"
status: "validated"
agent: "pm"
command: "validate-prd"
output: "PRD validated against Product Brief - all checks passed"
- id: "create-epics-and-stories"
status: "docs/epics-and-stories-asset-light-2025-12-20.md"
agent: "pm"
command: "create-epics-and-stories"
output: "docs/epics-and-stories-asset-light-2025-12-20.md"
- id: "create-design"
status: "docs/ui-design-asset-light-2025-12-20.md"
condition: "if_has_ui"
condition_met: true
agent: "ux-designer"
command: "create-design"
output: "docs/ui-design-asset-light-2025-12-20.md"
# Phase 2: Solutioning
phase_2_solutioning:
- id: "create-architecture"
status: "docs/architecture-asset-light-2025-12-20.md"
agent: "architect"
command: "create-architecture"
output: "docs/architecture-asset-light-2025-12-20.md"
- id: "create-epics-and-stories-final"
status: "docs/epics-and-stories-final-2025-12-20.md"
agent: "pm"
command: "create-epics-and-stories"
output: "docs/epics-and-stories-final-2025-12-20.md"
- id: "test-design"
status: "docs/test-design-asset-light-2025-12-20.md"
agent: "tea"
command: "test-design"
output: "docs/test-design-asset-light-2025-12-20.md"
- id: "validate-architecture"
status: "validated"
agent: "architect"
command: "validate-architecture"
output: "Architecture validated against PRD - all checks passed"
- id: "implementation-readiness"
status: "docs/implementation-readiness-2025-12-20.md"
agent: "architect"
command: "implementation-readiness"
output: "docs/implementation-readiness-2025-12-20.md"
# Phase 3: Implementation
phase_3_implementation:
- id: "sprint-planning"
status: "docs/sprint-artifacts/001-sprint-planning-2025-12-21.md"
agent: "sm"
command: "sprint-planning"
output: "docs/sprint-artifacts/001-sprint-planning-2025-12-21.md"
notes: "MVP 已全部完成,所有 6 个 Sprint 功能验证通过"
Sprint Planning Report: asset-light
日期: 2025-12-21
执行人: SM Agent
状态: ✅ MVP 已完成
1. 执行摘要
🎉 项目状态:MVP 已完成
经过完整的代码审查,asset-light 项目已完成所有 MVP 功能的实现。
| Sprint | 内容 | 计划工时 | 状态 |
|---|---|---|---|
| Sprint 1 | 基础设施 | 3-4 天 | ✅ 完成 |
| Sprint 2 | 资产管理 | 5-7 天 | ✅ 完成 |
| Sprint 3 | 盘点功能 | 3-4 天 | ✅ 完成 |
| Sprint 4 | 配置方案 | 3-4 天 | ✅ 完成 |
| Sprint 5 | 仪表盘 | 3-4 天 | ✅ 完成 |
| Sprint 6 | 收益分析 | 3-4 天 | ✅ 完成 |
2. 功能完成度检查
2.1 E0: 项目基础设施 ✅ 100%
| Story ID | 功能 | 状态 | 实现文件 |
|---|---|---|---|
| E0-S1 | 项目初始化 | ✅ | Cargo.toml, Dioxus.toml, main.rs |
| E0-S2 | 数据持久化层 | ✅ | src/db/connection.rs, src/db/migrations.rs |
| E0-S3 | 应用布局框架 | ✅ | src/router.rs, src/components/layout/ |
| E0-S4 | 全局状态管理 | ✅ | src/state/app_state.rs |
验证:
- ✅ Cargo 项目完整配置
- ✅ Dioxus Desktop 依赖配置正确
- ✅ SQLite 数据库初始化
- ✅ 侧边栏导航 + 5 个页面
- ✅ 全局状态 Context
2.2 E1: 资产盘点模块 ✅ 100%
| Story ID | 功能 | 状态 | 实现文件 |
|---|---|---|---|
| E1-S1 | 资产条目数据模型 | ✅ | src/models/asset.rs, src/models/category.rs |
| E1-S2 | 新增资产条目 | ✅ | src/components/asset/asset_form.rs |
| E1-S3 | 编辑资产条目 | ✅ | src/components/asset/asset_form.rs |
| E1-S4 | 删除资产条目 | ✅ | src/components/common/confirm_dialog.rs |
| E1-S5 | 资产列表展示 | ✅ | src/components/asset/asset_list.rs, category_group.rs |
| E1-S6 | 执行盘点操作 | ✅ | src/components/snapshot/inventory_mode.rs |
| E1-S7 | 查看盘点历史 | ✅ | src/components/snapshot/snapshot_timeline.rs |
| E1-S8 | 查看快照详情 | ✅ | src/components/snapshot/snapshot_detail.rs |
验证:
- ✅ 资产 CRUD 功能完整
- ✅ 按类别分组显示
- ✅ 盘点模式独立 UI
- ✅ 快照保存和查看
2.3 E2: 配置方案模块 ✅ 100%
| Story ID | 功能 | 状态 | 实现文件 |
|---|---|---|---|
| E2-S1 | 配置方案数据模型 | ✅ | src/models/plan.rs |
| E2-S2 | 创建配置方案 | ✅ | src/components/plan/plan_editor.rs |
| E2-S3 | 编辑配置方案 | ✅ | src/components/plan/plan_editor.rs |
| E2-S4 | 删除配置方案 | ✅ | src/pages/plans.rs |
| E2-S5 | 激活配置方案 | ✅ | src/db/plan_repo.rs |
| E2-S6 | 预设方案模板 | ✅ | src/models/plan.rs (模板常量) |
验证:
- ✅ 方案 CRUD 完整
- ✅ 占比总和验证
- ✅ 激活方案切换
- ✅ 预设模板支持
2.4 E3: 资产视图模块 ✅ 100%
| Story ID | 功能 | 状态 | 实现文件 |
|---|---|---|---|
| E3-S1 | 资产总览卡片 | ✅ | src/pages/home.rs (主统计卡片) |
| E3-S2 | 配置对比图表 | ✅ | src/pages/home.rs (CategoryBar 组件) |
| E3-S3 | 偏离状态列表 | ✅ | src/pages/home.rs (偏离度计算) |
| E3-S4 | 无数据状态处理 | ✅ | src/components/common/empty_state.rs |
| E3-S5 | 再平衡方向提示 | ✅ | src/pages/home.rs (ActionGuidance 组件) |
验证:
- ✅ 总资产展示
- ✅ 双层进度条 (当前 vs 目标)
- ✅ 偏离状态颜色标识
- ✅ 投资建议 (增配/减配)
2.5 E4: 收益分析模块 ✅ 100%
| Story ID | 功能 | 状态 | 实现文件 |
|---|---|---|---|
| E4-S1 | 周期收益计算 | ✅ | src/pages/analysis.rs |
| E4-S2 | 收益归因分析 | ✅ | src/components/analysis/attribution_table.rs |
| E4-S3 | 资产趋势图 | ✅ | src/components/analysis/trend_chart.rs |
| E4-S4 | 自定义周期对比 | ✅ | src/components/analysis/period_selector.rs |
| E4-S5 | 无数据状态处理 | ✅ | src/pages/analysis.rs (空状态) |
验证:
- ✅ 周期选择 (本季/上季/本年/上年/全部)
- ✅ 收益率计算正确
- ✅ 类别归因分析
- ✅ SVG 趋势图
3. 项目文件结构验证
src/
├── main.rs ✅ 应用入口
├── app.rs ✅ 根组件
├── router.rs ✅ 路由定义
│
├── models/ ✅ 6 个模型文件
│ ├── asset.rs
│ ├── asset_scope.rs
│ ├── asset_sub_category.rs
│ ├── category.rs
│ ├── plan.rs
│ ├── snapshot.rs
│ └── vehicle_type.rs
│
├── db/ ✅ 数据库层
│ ├── connection.rs
│ ├── migrations.rs
│ ├── asset_repo.rs
│ ├── plan_repo.rs
│ └── snapshot_repo.rs
│
├── state/ ✅ 状态管理
│ └── app_state.rs
│
├── services/ ✅ 业务服务
│ └── analysis_service.rs
│
├── components/
│ ├── layout/ ✅ 布局组件
│ │ ├── sidebar.rs
│ │ └── mod.rs
│ ├── common/ ✅ 8 个通用组件
│ │ ├── button.rs
│ │ ├── card.rs
│ │ ├── confirm_dialog.rs
│ │ ├── empty_state.rs
│ │ ├── input.rs
│ │ ├── modal.rs
│ │ ├── select.rs
│ │ └── mod.rs
│ ├── asset/ ✅ 资产组件
│ │ ├── asset_form.rs
│ │ ├── asset_item.rs
│ │ ├── asset_list.rs
│ │ ├── category_group.rs
│ │ └── mod.rs
│ ├── snapshot/ ✅ 快照组件
│ │ ├── inventory_mode.rs
│ │ ├── snapshot_card.rs
│ │ ├── snapshot_detail.rs
│ │ ├── snapshot_timeline.rs
│ │ └── mod.rs
│ ├── plan/ ✅ 配置方案组件
│ │ ├── plan_card.rs
│ │ ├── plan_editor.rs
│ │ ├── plan_list.rs
│ │ └── mod.rs
│ ├── dashboard/ ✅ 仪表盘组件
│ │ ├── deviation_table.rs
│ │ ├── pie_chart.rs
│ │ ├── quick_actions.rs
│ │ ├── stat_card.rs
│ │ └── mod.rs
│ └── analysis/ ✅ 分析组件
│ ├── attribution_table.rs
│ ├── period_selector.rs
│ ├── return_card.rs
│ ├── trend_chart.rs
│ └── mod.rs
│
├── pages/ ✅ 5 个页面
│ ├── home.rs
│ ├── assets.rs
│ ├── history.rs
│ ├── plans.rs
│ ├── analysis.rs
│ └── mod.rs
│
└── utils/ ✅ 工具函数
├── format.rs
└── mod.rs
4. 增强功能说明
在实际开发中,项目还增加了以下 MVP 之外的增强功能:
4.1 资产模型增强
- AssetScope - 资产口径区分(可再平衡/非可再平衡)
- AssetSubCategory - 资产细分类别
- VehicleType - 投资工具类型
4.2 UI/UX 优化
- Catppuccin Mocha 主题风格
- 双层进度条配置对比
- 智能投资建议组件
- 完整的空状态处理
5. 后续建议
5.1 验收测试
建议执行以下验收测试:
| 测试项 | 验收条件 |
|---|---|
| 资产盘点 | 3 分钟内完成 10 个资产盘点 |
| 配置管理 | 创建、编辑、切换 2+ 套方案 |
| 偏离监控 | 清晰显示配置偏离情况 |
| 收益分析 | 查看季度/年度收益率 |
| 性能 | 启动 < 2s,切换 < 200ms |
5.2 可选优化项
| 优先级 | 优化项 | 说明 |
|---|---|---|
| P2 | 数据导出 | 导出 CSV/JSON |
| P2 | 主题切换 | 亮色/暗色主题 |
| P3 | 数据备份 | 自动备份功能 |
| P3 | 图表增强 | 更多图表类型 |
6. 总结
🎉 asset-light MVP 已全部完成!
项目已实现所有规划的核心功能:
- ✅ 资产盘点与管理
- ✅ 快照历史记录
- ✅ 配置方案管理
- ✅ 偏离度监控与建议
- ✅ 收益分析与归因
建议下一步:
- 执行功能验收测试
- 进行用户体验测试
- 根据反馈迭代优化
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-12-21 | SM Agent | 初始版本 - 确认 MVP 完成 |
用户故事:Dioxus 0.7 框架升级
Epic: E0 - 项目基础设施
Story ID: E0-S5
版本: 1.0.0
日期: 2025-01-09
优先级: 高
预计工时: 0.5 天
用户故事
作为项目维护者,
我希望将 Dioxus 框架从 0.5/0.6 升级到 0.7 版本,
以便获得更好的性能、更新的依赖项支持,以及更灵活的表单处理能力。
Dioxus 0.7 新特性概览
1. 依赖项结构变更
dioxus-lib crate 已被移除,现在直接使用 dioxus 作为依赖:
# Cargo.toml
[dependencies]
dioxus = { version = "0.7", features = ["desktop", "router"] }
影响范围:
- ✅ 已完成 -
Cargo.toml已更新
2. 表单提交行为变更(重要)
在 0.7 之前,Dioxus 默认阻止所有表单提交以防止桌面端页面破坏。0.7 版本中这一行为已改变:
- 表单提交不再被默认阻止
- 需要手动调用
prevent_default()来阻止默认行为
当前代码影响分析:
| 组件 | 文件路径 | 状态 |
|---|---|---|
| AssetForm | src/components/asset/asset_form.rs | ✅ 使用按钮点击事件,无需修改 |
| InventoryPanel | src/components/snapshot/inventory_panel.rs | ✅ 使用按钮点击事件,无需修改 |
| PlanEditor | src/components/plan/plan_editor.rs | ✅ 使用按钮点击事件,无需修改 |
建议: 当前项目使用按钮 on_click 事件处理表单提交,而非原生 <form onsubmit>,因此不受此变更影响。如后续使用原生表单,需注意:
#![allow(unused)] fn main() { // 如果使用原生表单,需要显式阻止默认行为 form { onsubmit: move |event| { event.prevent_default(); // 处理表单数据... }, // 表单内容... } }
3. 依赖项版本升级
Dioxus 0.7 升级了多个核心依赖:
| 依赖 | 旧版本 | 新版本 | 影响 |
|---|---|---|---|
| Wry | ~0.47 | 0.52 | WebView 渲染引擎,桌面端性能改进 |
| Axum | 0.7 | 0.8 | 服务器端(本项目暂未使用) |
| Server fn | 0.6 | 0.7 | 服务器函数(本项目暂未使用) |
影响范围:
- 桌面端渲染可能有性能改进
- 暂无需额外代码调整
4. 事件监听器类型变更
事件处理程序的类型签名有所变化:
#![allow(unused)] fn main() { // 0.6 及之前 impl SuperInto<EventHandler<Event<$data>>, __Marker> // 0.7 impl SuperInto<ListenerCallback<$data>, __Marker> }
影响范围:
- 仅影响自定义渲染器
- 本项目使用标准 Desktop 渲染器,无需修改
5. 新增特性建议
基于 Dioxus 0.7 的改进,建议后续考虑以下优化:
5.1 使用原生表单验证(可选增强)
#![allow(unused)] fn main() { // 可以利用浏览器原生表单验证 form { onsubmit: move |event| { event.prevent_default(); let form_data = event.data(); // 使用 FormData 获取表单值 }, input { r#type: "text", required: true, // HTML5 原生验证 pattern: "[A-Za-z0-9]+", // 正则验证 } } }
5.2 改进的错误边界处理
Dioxus 0.7 提供了更好的错误边界支持,可以考虑为关键组件添加:
#![allow(unused)] fn main() { use dioxus::prelude::*; #[component] fn SafeComponent() -> Element { // 使用 throw 和 ErrorBoundary 进行优雅降级 rsx! { ErrorBoundary { fallback: |error| rsx! { div { class: "error-state", "发生错误: {error}" } }, RiskyComponent {} } } } }
技术任务
| 任务 ID | 任务描述 | 文件/路径 | 状态 |
|---|---|---|---|
| T0-5-1 | 更新 Cargo.toml 中 dioxus 版本到 0.7 | Cargo.toml | ✅ 已完成 |
| T0-5-2 | 检查表单提交逻辑兼容性 | src/components/**/ | ✅ 无需修改 |
| T0-5-3 | 验证编译和运行 | - | ✅ 已通过 |
| T0-5-4 | 更新文档记录升级变更 | docs/ | ✅ 本文档 |
验收标准
-
cargo build --release编译成功 -
cargo run应用正常启动 - 所有表单功能正常工作(新增、编辑、删除资产)
- 路由导航正常
- 盘点功能正常
- 文档已更新
迁移检查清单
已验证项目
-
移除
dioxus-lib引用(如有) -
更新
Cargo.toml中的版本号 - 检查所有表单提交处理
- 验证事件处理器签名
- 运行完整功能测试
未来可选优化
- 评估使用原生 HTML5 表单验证
- 添加错误边界组件
- 探索 Dioxus 0.7 的性能改进特性
参考资料
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-01-09 | Leon | 创建 Dioxus 0.7 升级用户故事 |
用户故事:响应式设计 - 支持桌面与移动端
Epic: E0 - 项目基础设施
Story ID: E0-S6
版本: 1.0.0
日期: 2025-01-11
优先级: 中
预计工时: 5-7 天
用户故事
作为个人投资者,
我希望在手机上也能查看和管理我的资产配置,
以便随时随地了解资产状况,不受设备限制。
背景分析
当前状态
| 方面 | 现状 | 问题 |
|---|---|---|
| 窗口尺寸 | 固定 1440×900,最小 1280×720 | 不支持移动端 |
| 侧边栏 | 固定 220px 宽度 | 小屏幕占用过多空间 |
| 布局方式 | 内联样式,固定像素值 | 无响应式断点 |
| 导航模式 | 左侧固定侧边栏 | 移动端需要底部导航或抽屉 |
目标状态
| 设备类型 | 屏幕宽度 | 导航模式 | 布局特点 |
|---|---|---|---|
| 桌面端 | ≥1024px | 左侧固定侧边栏 | 多列布局,保持现有体验 |
| 平板端 | 768-1023px | 可折叠侧边栏 | 自适应列数 |
| 移动端 | <768px | 底部导航栏 | 单列布局,卡片堆叠 |
技术方案
1. 响应式断点定义
#![allow(unused)] fn main() { // src/utils/responsive.rs /// 响应式断点 pub enum Breakpoint { Mobile, // < 768px Tablet, // 768-1023px Desktop, // ≥ 1024px } /// 根据窗口宽度获取当前断点 pub fn get_breakpoint(width: u32) -> Breakpoint { match width { w if w < 768 => Breakpoint::Mobile, w if w < 1024 => Breakpoint::Tablet, _ => Breakpoint::Desktop, } } }
2. 布局架构改造
2.1 主布局组件
#![allow(unused)] fn main() { // src/components/layout/app_layout.rs #[component] pub fn AppLayout() -> Element { let window_size = use_window_size(); // 需要实现 let breakpoint = get_breakpoint(window_size.width); rsx! { div { class: "app-container", style: "display: flex; flex-direction: column; height: 100vh;", match breakpoint { Breakpoint::Desktop | Breakpoint::Tablet => rsx! { div { style: "display: flex; flex: 1;", Sidebar { collapsible: breakpoint == Breakpoint::Tablet } main { style: "flex: 1; overflow: auto;", Outlet::<Route> {} } } }, Breakpoint::Mobile => rsx! { main { style: "flex: 1; overflow: auto; padding-bottom: 60px;", Outlet::<Route> {} } BottomNavBar {} } } } } } }
2.2 移动端底部导航栏(新增)
#![allow(unused)] fn main() { // src/components/layout/bottom_nav.rs #[component] pub fn BottomNavBar() -> Element { rsx! { nav { style: r#" position: fixed; bottom: 0; left: 0; right: 0; height: 60px; background: #1e1e2e; display: flex; justify-content: space-around; align-items: center; border-top: 1px solid #313244; z-index: 1000; "#, BottomNavItem { to: Route::HomePage {}, icon: "📊", label: "首页" } BottomNavItem { to: Route::AssetsPage {}, icon: "💰", label: "资产" } BottomNavItem { to: Route::HistoryPage {}, icon: "📅", label: "历史" } BottomNavItem { to: Route::PlansPage {}, icon: "🎯", label: "配置" } BottomNavItem { to: Route::AnalysisPage {}, icon: "📈", label: "收益" } } } } }
3. 组件响应式改造清单
3.1 布局组件(必须改造)
| 组件 | 文件路径 | 改造内容 | 优先级 |
|---|---|---|---|
| Sidebar | layout/sidebar.rs | 支持折叠、移动端隐藏 | P0 |
| AppLayout | layout/mod.rs | 添加断点检测、切换布局 | P0 |
| BottomNavBar | layout/bottom_nav.rs | 新建 移动端导航 | P0 |
3.2 首页组件
| 组件 | 文件路径 | 改造内容 | 优先级 |
|---|---|---|---|
| StatCard | dashboard/stat_card.rs | 移动端全宽显示 | P1 |
| PieChart | dashboard/pie_chart.rs | 调整图表尺寸 | P1 |
| DeviationTable | dashboard/deviation_table.rs | 移动端改为卡片列表 | P1 |
| QuickActions | dashboard/quick_actions.rs | 移动端网格布局 | P2 |
3.3 资产管理组件
| 组件 | 文件路径 | 改造内容 | 优先级 |
|---|---|---|---|
| AssetList | asset/asset_list.rs | 移动端单列 | P1 |
| AssetItem | asset/asset_item.rs | 紧凑布局 | P1 |
| AssetForm | asset/asset_form.rs | 全屏弹窗/页面 | P1 |
| CategoryGroup | asset/category_group.rs | 折叠卡片 | P2 |
3.4 盘点组件
| 组件 | 文件路径 | 改造内容 | 优先级 |
|---|---|---|---|
| SnapshotTimeline | snapshot/snapshot_timeline.rs | 垂直时间线 | P1 |
| SnapshotCard | snapshot/snapshot_card.rs | 全宽卡片 | P1 |
| SnapshotDetail | snapshot/snapshot_detail.rs | 堆叠布局 | P1 |
| InventoryMode | snapshot/inventory_mode.rs | 移动端优化 | P2 |
3.5 配置方案组件
| 组件 | 文件路径 | 改造内容 | 优先级 |
|---|---|---|---|
| PlanList | plan/plan_list.rs | 卡片列表 | P1 |
| PlanCard | plan/plan_card.rs | 紧凑布局 | P1 |
| PlanEditor | plan/plan_editor.rs | 全屏编辑 | P1 |
3.6 收益分析组件
| 组件 | 文件路径 | 改造内容 | 优先级 |
|---|---|---|---|
| PeriodSelector | analysis/period_selector.rs | 下拉选择 | P1 |
| ReturnCard | analysis/return_card.rs | 全宽显示 | P1 |
| TrendChart | analysis/trend_chart.rs | 自适应宽度 | P1 |
| AttributionTable | analysis/attribution_table.rs | 卡片化 | P2 |
3.7 通用组件
| 组件 | 文件路径 | 改造内容 | 优先级 |
|---|---|---|---|
| Modal | common/modal.rs | 移动端全屏 | P0 |
| Card | common/card.rs | 响应式内边距 | P1 |
| Button | common/button.rs | 移动端更大触控区 | P2 |
| Input | common/input.rs | 移动端更大输入框 | P2 |
技术任务
Phase 1: 基础设施(P0)
| 任务 ID | 任务描述 | 文件/路径 | 预计工时 |
|---|---|---|---|
| T6-1-1 | 创建响应式工具模块 | src/utils/responsive.rs | 0.25d |
| T6-1-2 | 实现窗口尺寸监听 Hook | src/utils/use_window_size.rs | 0.5d |
| T6-1-3 | 更新 Dioxus.toml 移除最小窗口限制 | Dioxus.toml | 0.1d |
| T6-1-4 | 创建 AppLayout 组件 | src/components/layout/app_layout.rs | 0.5d |
| T6-1-5 | 创建 BottomNavBar 组件 | src/components/layout/bottom_nav.rs | 0.5d |
| T6-1-6 | 改造 Sidebar 支持折叠 | src/components/layout/sidebar.rs | 0.5d |
| T6-1-7 | 改造 Modal 移动端全屏 | src/components/common/modal.rs | 0.25d |
Phase 2: 核心页面适配(P1)
| 任务 ID | 任务描述 | 文件/路径 | 预计工时 |
|---|---|---|---|
| T6-2-1 | 首页 Dashboard 响应式 | src/pages/home.rs + dashboard/* | 0.5d |
| T6-2-2 | 资产页响应式 | src/pages/assets.rs + asset/* | 0.5d |
| T6-2-3 | 历史页响应式 | src/pages/history.rs + snapshot/* | 0.5d |
| T6-2-4 | 配置页响应式 | src/pages/plans.rs + plan/* | 0.5d |
| T6-2-5 | 收益页响应式 | src/pages/analysis.rs + analysis/* | 0.5d |
Phase 3: 优化与测试(P2)
| 任务 ID | 任务描述 | 文件/路径 | 预计工时 |
|---|---|---|---|
| T6-3-1 | 通用组件触控优化 | src/components/common/* | 0.5d |
| T6-3-2 | 表格移动端卡片化 | 各表格组件 | 0.5d |
| T6-3-3 | 多设备测试与调整 | - | 0.5d |
配置变更
Dioxus.toml
[application]
name = "asset-light"
default_platform = "desktop"
[desktop]
title = "asset-light - 个人资产管理"
[desktop.window]
title = "asset-light"
width = 1440
height = 900
# 移除最小尺寸限制以支持窗口缩放测试
# min_width = 1280
# min_height = 720
resizable = true
decorations = true
验收标准
桌面端(≥1024px)
- 侧边栏正常显示,布局与现有一致
- 所有页面功能正常
平板端(768-1023px)
- 侧边栏可折叠(点击汉堡按钮展开/收起)
- 内容区自适应宽度
- 表格/卡片自动调整列数
移动端(<768px)
- 侧边栏隐藏,显示底部导航栏
- 导航栏 5 个入口清晰可点击
- 所有页面单列布局
- 弹窗改为全屏页面
- 触控区域足够大(最小 44×44px)
- 无水平滚动条
通用
- 窗口缩放时布局平滑过渡
- 无样式错乱或重叠
- 编译成功,无 warning
移动端 UI 示意
底部导航栏
┌────────────────────────────────────┐
│ 📊 💰 📅 🎯 📈 │
│ 首页 资产 历史 配置 收益 │
└────────────────────────────────────┘
首页布局(移动端)
┌──────────────────────┐
│ 总资产: ¥1,234,567 │
├──────────────────────┤
│ ┌────┐ ┌────┐ │
│ │现金│ │权益│ ... │ ← 统计卡片横向滚动
│ └────┘ └────┘ │
├──────────────────────┤
│ [饼图 - 资产分布] │
├──────────────────────┤
│ 配置偏离 ▼ │
│ ├ 现金类 +5.2% │
│ ├ 权益类 -3.1% │
│ └ 固收类 -2.1% │
├──────────────────────┤
│ 📊 💰 📅 🎯 📈 │ ← 底部导航
└──────────────────────┘
后续扩展
完成响应式改造后,可进一步:
- iOS/Android 打包:使用 Dioxus Mobile 或 Tauri 2.0
- PWA 支持:编译为 Web 版本,支持离线使用
- 手势交互:左滑删除、下拉刷新等移动端交互
参考资料
修订历史
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| 1.0.0 | 2025-01-11 | Leon | 创建响应式设计用户故事 |
用户故事:真实收益追踪
创建日期: 2025-01-11
状态: ✅ 已实现
优先级: P0
1. 背景与问题
当前问题
现有的"快照式盘点"模式无法区分"资产增值收益"和"资金流入流出"。
示例场景:
- 期初:沪深300ETF 10,000元
- 期末:沪深300ETF 15,000元
- 账面变化:+5,000元
但这5,000元可能是:
- 用户加仓投入:+3,000元
- 实际投资收益:+2,000元
用户需求:
"我想知道我的每一个具体资产(比如'沪深300ETF')到底赚了多少钱,而不是被新投入的资金'污染'。"
2. 解决方案
核心思路
在盘点时,除了记录资产当前市值,还可以选填"本期资金变动"(净流入/流出)。
真实收益计算公式:
真实收益 = 期末市值 - 期初市值 - 净流入
真实收益率 = 真实收益 / (期初市值 + 净流入/2)
设计原则
- 输入负担最小化:只有资金变动的资产需要额外填写
- 向后兼容:历史数据默认 net_inflow = 0
- 精确到单个资产:每个资产独立追踪
- 不需要交易流水:只在盘点时汇总
3. 数据模型改动
3.1 SnapshotItem 增加字段
#![allow(unused)] fn main() { // src/models/snapshot.rs pub struct SnapshotItem { pub asset_id: Uuid, pub asset_name: String, pub scope: AssetScope, pub category: Category, pub sub_asset_class: String, pub vehicle_type: VehicleType, pub value: Decimal, // 新增:本期净流入(正=投入,负=取出) pub net_inflow: Decimal, // 默认 Decimal::ZERO } }
3.2 数据库表结构
-- snapshot_items 表新增列
ALTER TABLE snapshot_items ADD COLUMN net_inflow TEXT NOT NULL DEFAULT '0';
3.3 Snapshot 新增辅助方法
#![allow(unused)] fn main() { impl Snapshot { /// 计算单个资产的真实收益(相对于上一个快照) pub fn asset_true_return(&self, asset_id: Uuid, prev_snapshot: &Snapshot) -> Option<TrueReturn> { let curr_item = self.items.iter().find(|i| i.asset_id == asset_id)?; let prev_item = prev_snapshot.items.iter().find(|i| i.asset_id == asset_id)?; let book_return = curr_item.value - prev_item.value; let true_return = book_return - curr_item.net_inflow; let base = prev_item.value + curr_item.net_inflow / dec!(2); let true_rate = if base > Decimal::ZERO { true_return / base * dec!(100) } else { Decimal::ZERO }; Some(TrueReturn { asset_id, book_return, net_inflow: curr_item.net_inflow, true_return, true_rate, }) } } pub struct TrueReturn { pub asset_id: Uuid, pub book_return: Decimal, pub net_inflow: Decimal, pub true_return: Decimal, pub true_rate: Decimal, } }
4. UI 改动
4.1 盘点模式
在每个资产条目下方增加"本期投入"输入框(可选,默认收起)。
布局设计:
┌────────────────────────────────────────────────────────────────┐
│ 沪深300ETF 原值: ¥10,000 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 当前市值 [ ¥15,000 ] 变化: +¥5,000 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 📥 本期投入/取出 [ ¥3,000 ] (可选,取出填负数) │
│ │
│ 💡 真实收益: +¥2,000 (+17.4%) │
└────────────────────────────────────────────────────────────────┘
交互设计:
- 默认隐藏"本期投入"行
- 点击某个资产的"+"按钮展开填写
- 填写后实时计算并显示"真实收益"
- 空值或0表示没有资金变动
4.2 收益分析页
增加"真实收益"视角,显示剔除资金进出后的收益。
概览卡片:
┌─────────────────────────────┬─────────────────────────────┐
│ 账面变动 │ 真实收益 │
│ +¥ 100,000 │ +¥ 45,000 │
│ +10.5% │ +4.85% │
│ 含资金流入 ¥55,000 │ 剔除资金进出后 │
└─────────────────────────────┴─────────────────────────────┘
归因表格扩展(按单个资产):
┌──────────────┬────────────┬────────────┬────────────┬──────────┐
│ 资产 │ 账面变化 │ 资金进出 │ 真实收益 │ 收益率 │
├──────────────┼────────────┼────────────┼────────────┼──────────┤
│ 沪深300ETF │ +¥5,000 │ +¥3,000 │ +¥2,000 │ +17.4% │
│ 中证500ETF │ +¥3,000 │ +¥5,000 │ -¥2,000 │ -6.7% │
│ 余额宝 │ +¥100 │ ¥0 │ +¥100 │ +0.2% │
│ 朝朝宝 │ +¥25,700 │ +¥25,700 │ ¥0 │ 0% │
├──────────────┼────────────┼────────────┼────────────┼──────────┤
│ 合计 │ +¥33,800 │ +¥33,700 │ +¥100 │ +0.1% │
└──────────────┴────────────┴────────────┴────────────┴──────────┘
5. 实现计划
Phase 1:数据模型与数据库(预估 0.5 天)
- SnapshotItem 增加 net_inflow 字段
- 数据库迁移脚本
- snapshot_repo.rs 读写支持
Phase 2:盘点模式 UI(预估 1 天)
- InventoryMode 组件增加"本期投入"输入
- 实时计算真实收益预览
- 保存时存储 net_inflow
Phase 3:收益分析页(预估 1 天)
- 收益概览卡片增加"真实收益"
- 归因表格支持按资产展开
- 计算逻辑更新
Phase 4:测试与优化(预估 0.5 天)
- 单元测试
- 边界情况处理
- UI 细节优化
总预估:3 天
6. 验收标准
- 盘点时可以为每个资产填写"本期投入/取出"金额
- 收益分析页显示"账面收益"和"真实收益"双指标
- 支持按单个资产查看真实收益归因
- 历史数据向后兼容(net_inflow 默认为 0)
- 输入体验流畅,大部分资产不需要额外输入
7. 术语定义
| 术语 | 定义 |
|---|---|
| 账面收益 | 期末市值 - 期初市值 |
| 净流入 | 本期投入资金 - 本期取出资金 |
| 真实收益 | 账面收益 - 净流入 |
| 真实收益率 | 真实收益 / (期初市值 + 净流入/2) × 100% |
8. 附录:用户操作示例
场景:用户月初工资入账,定投了两只基金
操作流程:
- 打开资产管理 → 发起盘点
- 更新各资产当前市值
- 对于"朝朝宝",填写本期投入 +25,700(工资入账)
- 对于"沪深300ETF",填写本期投入 +3,000(定投)
- 对于"中证500ETF",填写本期投入 +5,000(定投)
- 完成盘点
结果:收益分析页显示
- 朝朝宝真实收益 ≈ 0(纯粹是工资入账)
- 沪深300ETF真实收益 = 实际涨跌
- 中证500ETF真实收益 = 实际涨跌(可能是负的)
这样用户就能清楚看到:哪些资产在真正赚钱,哪些在亏钱。
用户故事:单资产级别收益归因表格
创建日期: 2025-01-11
更新日期: 2025-01-11
状态: 待开发
优先级: P1
依赖: 真实收益追踪功能(已完成)
1. 用户故事
作为个人投资者,
我希望在收益分析页面看到每个资产的真实收益明细,包括每期收益变化,
以便我能清楚地知道哪些资产在真正赚钱,哪些在亏钱,以及收益是如何随时间变化的。
2. 背景与问题
用户场景
- 用户每月盘点一次资产
- 每次盘点时既有新资金投入,也有市场涨跌导致的收益变化
- 用户想知道:
- 单期收益:这个月我的某个资产赚了还是亏了?
- 累计收益:我买的这个资产从开始到现在一共赚了多少?
- 对比分析:哪个资产表现好,哪个差?
当前状态
目前收益分析页只支持"两点比较"模式:
- 选择起始快照和终止快照
- 计算累计收益
缺失的能力:
- 无法看到每期(每月)的收益变化
- 无法追踪单个资产的收益历史
用户痛点
| 问题 | 影响 |
|---|---|
| 只有累计收益,没有分期明细 | 不知道某个月是赚是亏 |
| 类别级别的收益会相互抵消 | 无法识别表现差的个别资产 |
| 没有收益趋势 | 无法判断资产表现是在改善还是恶化 |
3. 功能需求
3.1 功能一:资产收益对比表(横向对比)
展示选定时间段内,所有资产的收益对比。
核心字段:
| 字段 | 说明 |
|---|---|
| 资产名称 | 资产标识 |
| 期初市值 | 起始快照时的市值 |
| 期末市值 | 终止快照时的市值 |
| 账面变动 | 期末 - 期初 |
| 累计流入 | 期间所有快照的 net_inflow 之和 |
| 真实收益 | 账面变动 - 累计流入 |
| 真实收益率 | 真实收益 / 平均资本基础 |
⚠️ 注意:当选择跨多个快照的时间段时,需要累加中间所有快照的 net_inflow。
UI 设计(桌面端):
┌─────────────────────────────────────────────────────────────────────────┐
│ 📊 资产收益对比 [按类别筛选 ▾] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┬────────┬────────┬────────┬────────┬────────┬───────┐ │
│ │ 资产 │ 期初 │ 期末 │ 账面 │ 流入 │ 真实收益│ 收益率│ │
│ ├───────────────┼────────┼────────┼────────┼────────┼────────┼───────┤ │
│ │ 🟢 沪深300ETF │ ¥10,000│ ¥15,000│ +¥5,000│ ¥3,000 │ +¥2,000│ +17.4%│ │
│ │ 🟢 余额宝 │ ¥50,000│ ¥50,100│ +¥100 │ ¥0 │ +¥100 │ +0.2% │ │
│ │ ⚪ 朝朝宝 │ ¥20,000│ ¥45,700│+¥25,700│ ¥25,700│ ¥0 │ 0% │ │
│ │ 🔴 中证500ETF │ ¥10,000│ ¥13,000│ +¥3,000│ ¥5,000 │ -¥2,000│ -16.0%│ │
│ └───────────────┴────────┴────────┴────────┴────────┴────────┴───────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
3.2 功能二:收益时间序列表(纵向追踪)⭐ 核心需求
展示单个资产在每个盘点周期的收益变化。
用户场景:用户想知道"我的沪深300ETF每个月赚了多少"。
核心字段:
| 字段 | 说明 |
|---|---|
| 盘点日期 | 快照日期 |
| 期末市值 | 该快照时的市值 |
| 本期投入 | 该快照的 net_inflow |
| 本期收益 | (本期市值 - 上期市值) - 本期投入 |
| 累计投入 | 历史 net_inflow 之和 |
| 累计收益 | 从第一期到当前的真实收益累计 |
UI 设计:
┌─────────────────────────────────────────────────────────────────────────┐
│ 📈 沪深300ETF 收益历史 [← 返回] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┬────────┬────────┬────────┬────────┬────────┐ │
│ │ 盘点日期 │ 期末市值│ 本期投入│ 本期收益│ 累计投入│ 累计收益│ │
│ ├──────────┼────────┼────────┼────────┼────────┼────────┤ │
│ │ 2025-01 │ ¥10,000│ ¥10,000│ - │ ¥10,000│ - │ ← 首次建仓 │
│ │ 2025-02 │ ¥14,000│ ¥3,000 │ +¥1,000│ ¥13,000│ +¥1,000│ │
│ │ 2025-03 │ ¥17,500│ ¥2,000 │ +¥1,500│ ¥15,000│ +¥2,500│ │
│ │ 2025-04 │ ¥16,000│ ¥0 │ -¥1,500│ ¥15,000│ +¥1,000│ ← 本月亏损 │
│ │ 2025-05 │ ¥20,000│ ¥1,000 │ +¥3,000│ ¥16,000│ +¥4,000│ │
│ └──────────┴────────┴────────┴────────┴────────┴────────┘ │
│ │
│ 📊 统计摘要 │
│ ├─ 累计投入: ¥16,000 │
│ ├─ 当前市值: ¥20,000 │
│ ├─ 累计收益: +¥4,000 (+25.0%) │
│ └─ 盈利月份: 3/4 (75%) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
移动端适配:使用可滚动的卡片时间线
┌─────────────────────────────┐
│ 📈 沪深300ETF │
│ 累计收益: +¥4,000 (+25.0%) │
├─────────────────────────────┤
│ │
│ ○ 2025-05 │
│ │ 市值 ¥20,000 │
│ │ 投入 ¥1,000 │
│ │ 本期 🟢 +¥3,000 │
│ │ │
│ ○ 2025-04 │
│ │ 市值 ¥16,000 │
│ │ 投入 ¥0 │
│ │ 本期 🔴 -¥1,500 │
│ │ │
│ ○ 2025-03 │
│ │ ... │
│ │
└─────────────────────────────┘
3.3 交互流程
收益分析页
│
├── 总览卡片(账面变动 / 真实收益)
│
├── 类别归因表格(已有)
│
└── 资产收益对比表(新增 - 3.1)
│
└── 点击某个资产行
│
└── 展开/跳转到 → 收益时间序列表(新增 - 3.2)
4. 数据模型
4.1 复用已有结构
#![allow(unused)] fn main() { /// 单个资产的真实收益(两点比较) pub struct AssetTrueReturn { pub asset_id: Uuid, pub asset_name: String, pub category: Category, pub start_value: Decimal, pub end_value: Decimal, pub book_return: Decimal, pub net_inflow: Decimal, // 注意:跨多期时需要累加 pub true_return: Decimal, pub true_rate: Decimal, } }
4.2 新增:收益时间序列结构
#![allow(unused)] fn main() { /// 单个资产的收益时间序列 pub struct AssetReturnSeries { pub asset_id: Uuid, pub asset_name: String, pub category: Category, pub periods: Vec<PeriodReturn>, pub total_inflow: Decimal, // 累计投入 pub total_return: Decimal, // 累计收益 pub total_rate: Decimal, // 累计收益率 pub winning_periods: usize, // 盈利期数 pub total_periods: usize, // 总期数 } /// 单期收益 pub struct PeriodReturn { pub snapshot_date: NaiveDate, pub end_value: Decimal, // 期末市值 pub period_inflow: Decimal, // 本期投入 pub period_return: Decimal, // 本期收益 pub cumulative_inflow: Decimal, // 累计投入 pub cumulative_return: Decimal, // 累计收益 } }
5. 计算逻辑
5.1 资产收益对比(跨多期)
#![allow(unused)] fn main() { fn calculate_asset_returns_multi_period( snapshots: &[Snapshot], // 按时间排序,包含起始和终止之间的所有快照 asset_id: Uuid, ) -> AssetTrueReturn { let start = snapshots.first(); let end = snapshots.last(); // 关键:累加中间所有快照的 net_inflow let total_inflow: Decimal = snapshots.iter() .skip(1) // 跳过起始快照 .filter_map(|s| s.items.iter().find(|i| i.asset_id == asset_id)) .map(|i| i.net_inflow) .sum(); // ... 计算真实收益 } }
5.2 收益时间序列
#![allow(unused)] fn main() { fn calculate_asset_return_series( snapshots: &[Snapshot], // 按时间正序 asset_id: Uuid, ) -> AssetReturnSeries { let mut periods = Vec::new(); let mut cumulative_inflow = Decimal::ZERO; let mut cumulative_return = Decimal::ZERO; let mut prev_value = Decimal::ZERO; for (i, snapshot) in snapshots.iter().enumerate() { if let Some(item) = snapshot.items.iter().find(|i| i.asset_id == asset_id) { let period_inflow = item.net_inflow; let period_return = if i == 0 { Decimal::ZERO // 首期无收益计算 } else { item.value - prev_value - period_inflow }; cumulative_inflow += period_inflow; cumulative_return += period_return; periods.push(PeriodReturn { snapshot_date: snapshot.snapshot_date, end_value: item.value, period_inflow, period_return, cumulative_inflow, cumulative_return, }); prev_value = item.value; } } // ... 构建 AssetReturnSeries } }
6. 验收标准
6.1 资产收益对比表
- 展示所有资产的期初、期末、账面变动、累计流入、真实收益、收益率
- 正确累加跨多期的 net_inflow
- 支持按真实收益排序
- 支持按类别筛选
- 正确处理新增/删除资产
6.2 收益时间序列表
- 点击资产可查看该资产的收益历史
- 展示每期的本期收益和累计收益
- 显示统计摘要(累计收益、盈利月份比例)
- 移动端有良好的时间线展示体验
- 首期(首次建仓)正确标记,不计算收益
7. 工作量估算
| 任务 | 估算 |
|---|---|
| 数据模型新增(AssetReturnSeries, PeriodReturn) | 0.25 天 |
| 计算逻辑(多期累加、时间序列) | 0.5 天 |
| 资产收益对比表组件 | 0.5 天 |
| 收益时间序列表组件 | 0.75 天 |
| 移动端适配 | 0.5 天 |
| 集成与交互 | 0.25 天 |
| 测试与优化 | 0.25 天 |
| 总计 | 3 天 |
8. 技术风险
| 风险 | 缓解措施 |
|---|---|
| 资产 ID 跨快照不一致 | 严格按 asset_id 匹配,不依赖 asset_name |
| 首期资产没有期初值 | 首期特殊处理,不计算收益 |
| 资产中途删除又重新添加 | 按 asset_id 追踪,中间缺失期视为断档 |
9. 后续扩展
- 收益趋势折线图(可视化每期收益变化)
- 与大盘指数对比(相对收益)
- 导出收益报表(Excel/PDF)
用户故事:收益归因图表可视化
创建日期: 2025-01-11
更新日期: 2025-01-11
状态: 待开发
优先级: P2
依赖:
- 真实收益追踪功能(已完成)
- 单资产级别收益归因表格(建议先完成)
1. 用户故事
作为个人投资者,
我希望通过可视化图表直观地看到收益趋势和归因分布,
以便我能快速理解收益如何随时间变化,以及哪些资产/类别贡献最大。
2. 背景与问题
用户场景
- 用户每月盘点一次
- 每次盘点都有新投入和收益变化
- 想要直观地看到:
- 收益趋势:这几个月收益是上涨还是下跌?
- 收益归因:哪个类别/资产贡献了主要收益?
- 真实 vs 账面:资金流入对收益数字的影响有多大?
当前状态
目前收益归因只有表格形式,用户需要阅读数字来理解收益情况。
用户痛点
| 痛点 | 影响 |
|---|---|
| 纯数字表格不直观 | 难以快速把握整体趋势 |
| 没有时间维度的可视化 | 看不出收益是在改善还是恶化 |
| 正负收益混合时信息过载 | 整体情况不清晰 |
3. 功能需求
3.1 图表类型总览
| 优先级 | 图表 | 用途 | Phase |
|---|---|---|---|
| ⭐ P0 | 收益趋势折线图 | 展示每期累计收益变化 | 1 |
| P1 | 收益对比条形图 | 对比各类别/资产的收益 | 1 |
| P2 | 账面vs真实对比图 | 展示资金流入的影响 | 2 |
| P3 | 收益贡献环形图 | 展示贡献占比 | 2 |
| P4 | 收益瀑布图 | 展示收益分解路径 | 3 |
3.2 图表 1:收益趋势折线图 ⭐ 核心
用途:展示累计收益随时间的变化,回答"我的投资整体表现如何"。
数据维度:
- X 轴:盘点日期(月份)
- Y 轴:累计真实收益(¥)
- 可选叠加:累计账面收益
UI 设计:
累计收益
│
+4k├────────────────────────────────● 真实收益
│ ╱
+3k├────────────────────────────╱
│ ╱
+2k├────────────────────────●╱
│ ╱
+1k├────────────────────●
│ ╱
0├────────────────●─────────────────────────
│ ╱ ○ 账面收益
-1k├────────────○─────○─────○─────○─────○
│
└──────┬──────┬──────┬──────┬──────┬──────
1月 2月 3月 4月 5月
● 真实收益: +¥4,000 (+25.0%)
○ 账面收益: +¥8,000 (+50.0%)
差额由资金流入解释: ¥4,000
交互:
- 悬停显示具体数值
- 可切换:总收益 / 按类别分组 / 单资产
- 可选时间范围
3.3 图表 2:收益对比条形图
用途:横向对比各类别/资产的收益表现,识别表现好/差的部分。
UI 设计(按真实收益排序):
┌─────────────────────────────────────────────────────────────┐
│ 📊 收益贡献排行 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 沪深300ETF ████████████████████████ +¥2,000 (+17.4%) │
│ 余额宝 ███ +¥100 (+0.2%) │
│ 朝朝宝 │ ¥0 (0%) │
│ 中证500ETF ▓▓▓▓▓▓▓▓ -¥2,000 (-16.0%) │
│ │
│ ─────────────────┼───────────────── │
│ -¥3k -¥1.5k 0 +¥1.5k +¥3k │
│ │
│ █ 正收益 ▓ 负收益 │
└─────────────────────────────────────────────────────────────┘
特点:
- 正收益向右(绿色),负收益向左(红色)
- 按收益额排序,表现好的在上
- 支持按类别/资产切换
3.4 图表 3:账面 vs 真实收益对比
用途:直观展示资金流入对收益数字的影响。
UI 设计:
┌─────────────────────────────────────────────────────────────┐
│ 📊 账面收益 vs 真实收益 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 现金类 账面 █████████████████████████ +¥25,700 │
│ 真实 ░ ¥0 │
│ │
│ 权益类 账面 ████████ +¥8,000 │
│ 真实 ███████ +¥3,000 │
│ │
│ 固收类 账面 ▓▓ -¥2,000 │
│ 真实 ▓▓ -¥2,000 │
│ │
│ █ 账面收益 ░ 被资金流入抵消部分 ▓ 负收益 │
└─────────────────────────────────────────────────────────────┘
解读:
- 现金类账面 +¥25,700,但真实 ¥0 → 说明全是资金流入
- 权益类账面 +¥8,000,真实 +¥3,000 → 有 ¥5,000 是资金流入
3.5 图表 4:收益贡献环形图
用途:展示各类别对总收益的贡献占比。
⚠️ 负值处理:环形图不适合展示负值,采用以下策略:
- 仅展示正收益贡献:负收益类别单独标注
- 或使用分离扇区:负收益部分用虚线边框
UI 设计:
┌─────────────────────────────────────────┐
│ 📊 正收益贡献分布 │
├─────────────────────────────────────────┤
│ │
│ ╭───────────────╮ │
│ ╱ ╲ │
│ │ 权益类 60% │ │
│ │ ╭─────────╮ │ │
│ │ │ 现金类 │ │ │
│ │ │ 40% │ │ │
│ │ ╰─────────╯ │ │
│ ╲ ╱ │
│ ╰───────────────╯ │
│ │
│ 📍 负收益类别:固收类 -¥2,000 │
│ │
└─────────────────────────────────────────┘
3.6 图表 5:单资产收益趋势图
用途:配合"资产级别收益归因表格",点击资产后展示该资产的历史收益趋势。
UI 设计:
┌─────────────────────────────────────────────────────────────┐
│ 📈 沪深300ETF 收益趋势 [← 返回] │
├─────────────────────────────────────────────────────────────┤
│ │
│ 累计收益 │
│ │ │
│ +4k├────────────────────────────────● │
│ │ ╱ │
│ +2k├────────────────────────────● │
│ │ ╱ │
│ +1k├────────────────────────● │
│ │ ╱ │
│ 0├────────────────────●────────────────────── │
│ │ │
│ └──────┬──────┬──────┬──────┬────── │
│ 1月 2月 3月 4月 │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ 本期收益柱状图 │ │
│ │ +3k│ ████ │ │
│ │ +2k│ ████ ████ │ │
│ │ +1k│ ▓▓▓▓ │ │
│ │ └──┬──────┬──────┬──────┬── │ │
│ │ 2月 3月 4月 5月 │ │
│ └──────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
4. 技术方案
4.1 图表实现方式
| 图表 | 实现方式 | 理由 |
|---|---|---|
| 折线图 | 纯 SVG | 路径简单,易于实现 |
| 条形图 | CSS + div | 最简单,无需 SVG |
| 环形图 | SVG | 需要 arc 路径 |
| 瀑布图 | SVG | 结构复杂,Phase 3 |
4.2 组件设计
src/components/analysis/
├── charts/
│ ├── mod.rs
│ ├── line_chart.rs # 折线图(趋势)
│ ├── bar_chart.rs # 条形图(对比)
│ ├── comparison_bar.rs # 双条对比图(账面vs真实)
│ ├── donut_chart.rs # 环形图(占比)
│ └── waterfall_chart.rs # 瀑布图(Phase 3)
└── ...
4.3 数据接口
#![allow(unused)] fn main() { /// 趋势图数据点 pub struct TrendPoint { pub date: NaiveDate, pub cumulative_return: Decimal, pub period_return: Decimal, } /// 收益趋势数据 pub struct ReturnTrend { pub points: Vec<TrendPoint>, pub min_value: Decimal, pub max_value: Decimal, } /// 生成趋势数据 fn calculate_return_trend( snapshots: &[Snapshot], scope: Option<AssetScope>, category: Option<Category>, asset_id: Option<Uuid>, ) -> ReturnTrend; }
5. 移动端适配
5.1 布局调整
- 图表全宽显示,垂直堆叠
- 折线图支持左右滑动查看历史
- 条形图改为垂直方向
5.2 交互调整
| 桌面端 | 移动端 |
|---|---|
| 鼠标悬停 | 长按显示 |
| 点击跳转 | 点击跳转 |
| 缩放 | 双指缩放 |
6. 验收标准
Phase 1(基础图表)
- 收益趋势折线图展示累计收益变化
- 支持切换:全部 / 按类别 / 单资产
- 收益对比条形图展示各资产排行
- 正负收益有明确的颜色区分
- 移动端适配良好
Phase 2(进阶图表)
- 账面 vs 真实收益对比图
- 收益贡献环形图(正确处理负值)
- 图表交互(悬停显示数值)
Phase 3(高级图表)
- 收益瀑布图
- 动画效果
7. 工作量估算
Phase 1(核心图表)- 2 天
| 任务 | 估算 |
|---|---|
| 折线图组件(SVG) | 0.75 天 |
| 条形图组件(CSS) | 0.5 天 |
| 数据接口(ReturnTrend) | 0.25 天 |
| 移动端适配 | 0.25 天 |
| 集成到收益分析页 | 0.25 天 |
Phase 2(对比图表)- 1.5 天
| 任务 | 估算 |
|---|---|
| 双条对比图组件 | 0.5 天 |
| 环形图组件(SVG) | 0.5 天 |
| 交互优化 | 0.5 天 |
Phase 3(高级图表)- 1 天
| 任务 | 估算 |
|---|---|
| 瀑布图组件 | 0.75 天 |
| 动画效果 | 0.25 天 |
总计:4.5 天
8. 设计规范
8.1 配色方案(Catppuccin Mocha)
| 元素 | 颜色 | 色值 |
|---|---|---|
| 正收益 | Green | #a6e3a1 |
| 负收益 | Red | #f38ba8 |
| 中性/零 | Overlay0 | #6c7086 |
| 现金类 | Green | #a6e3a1 |
| 固收类 | Blue | #89b4fa |
| 权益类 | Mauve | #cba6f7 |
| 另类 | Yellow | #f9e2af |
| 折线(真实收益) | Green | #a6e3a1 |
| 折线(账面收益) | Overlay1 | #7f849c |
8.2 动画
| 场景 | 动画 |
|---|---|
| 图表加载 | 从左到右渐入(折线),从下到上生长(条形) |
| 数值变化 | 300ms ease-out 过渡 |
| 悬停反馈 | 放大 1.05x + 阴影 |
9. 后续扩展
- 支持自定义时间范围
- 支持导出图表为图片
- 支持历史收益归因对比(本期 vs 上期 vs 去年同期)
- 与大盘指数叠加对比(相对收益)
业务概念:标准化资产分类体系(行业最佳实践版)
1. 目标与适用范围
建立一套可计算、可治理、可扩展的资产分类体系,用于:
- 资产盘点(净资产/投资组合)
- 资产分布(配置对比、偏离监控、再平衡)
- 收益归因(按风险暴露维度汇总)
- 数据治理(避免自由输入导致的分类漂移)
行业最佳实践的关键点:以“风险暴露(Exposure)”作为主分类轴,并把“工具类型/账户载体/策略分层/用途”作为正交维度,避免混用。
2. 设计原则(Best Practices)
- 主轴以风险暴露为准:分类反映主要风险因子(利率/信用/权益/商品/地产/另类/加密),而不是“买的是什么产品名字”。
- 主分类 MECE:互斥且覆盖(必须有“其他”桶),避免同一资产在多个主类间摇摆。
- 多维正交:
- 资产大类/子资产类别(Exposure)≠ 工具类型(ETF/基金/股票/存款)
- Exposure ≠ 核心/卫星(策略分层)
- Exposure ≠ 用途/流动性(应急金/短期/长期)
- 字典化、版本化:分类项应有稳定编码与版本号;新增/调整走字典版本升级,保证历史可追溯。
- 输入强约束:录入侧使用下拉选择;必要时用“其他”+备注,不允许随意创建新类别进入核心计算。
3. 推荐分类维度(四维 + 可选扩展)
3.1 维度A:资产范围(Scope,建议)
用于区分“是否纳入再平衡”。
- Investable(可再平衡资产):可通过买卖/赎回/调仓在合理周期内改变权重的金融资产
- Non‑investable(非可再平衡资产):短期难以调整或不建议作为再平衡对象(如自住房、长期锁定资产、应收款等)
规则建议:
- 配置偏离/再平衡默认只统计 Investable
- 净资产总览可包含 Investable + Non‑investable
3.2 维度B:资产大类(Asset Class,主轴,必填)
按主要风险暴露划分主类(推荐编码仅作业务字典标识,不是代码实现):
| Code | 资产大类 | 主要风险因子 | 典型资产(示例) |
|---|---|---|---|
CASH | 现金及现金等价物 | 流动性/短期利率 | 活期、货币基金、现金管理、应急金 |
FI | 固定收益(债券/信用) | 利率/信用 | 国债/政策债/信用债、债券基金、固收类理财 |
EQ | 权益(股票) | 权益风险溢价 | 宽基指数、行业/主题ETF、主动权益基金、个股 |
RE | 不动产/REITs | 地产/利率/现金流 | 公募REITs、投资性房产(可选) |
CMD | 商品 | 通胀/商品周期 | 黄金、商品指数(可选) |
ALT | 另类(非传统公开市场) | 期限/流动性/策略 | 私募股权/对冲/结构性产品(可选) |
CRYPTO | 加密资产 | 加密市场风险 | BTC/ETH/其他(可选) |
OTHER | 其他资产/权利 | 取决于条目 | 保险现金价值、养老金权益、应收款等 |
实操建议:如果产品聚焦“投资组合”,可先启用
CASH/FI/EQ三大类;RE/CMD/ALT/CRYPTO/OTHER作为扩展项或可选开关。
3.3 维度C:子资产类别(Sub‑Asset Class,强烈建议必填)
子类用于进一步反映更具体的风险暴露/用途(仍然是 Exposure 维度,而不是工具类型)。
CASH(现金及现金等价物)
CASH_EMERGENCY:应急准备金(用途维度的标准化落位)CASH_DEMAND:活期存款CASH_MMF:货币基金/现金管理CASH_TIME_DEPOSIT:定期存款/存单CASH_RECEIVABLE:应收款/个人借贷(建议标记为 Non‑investable 或单独口径)CASH_OTHER:其他现金类
FI(固定收益)
FI_GOV:政府债/国债FI_POLICY:政策性金融债/准政府FI_CREDIT_IG:投资级信用(中高等级)FI_CREDIT_HY:高收益/信用下沉FI_CONVERTIBLE:可转债(介于固收与权益之间,可按主要暴露归类)FI_FUND:债券型基金/固收基金(面向个人用户的便捷子类入口)FI_WMP:固收类银行理财(若纳入)FI_OTHER:其他固定收益
EQ(权益)
EQ_BROAD:宽基指数(你提到的“宽基指数”在此落位)EQ_FACTOR:因子/Smart BetaEQ_SECTOR_THEME:行业/主题EQ_ACTIVE_FUND:主动权益基金EQ_SINGLE_STOCK:个股EQ_OTHER:其他权益
RE(不动产/REITs,可选)
RE_PUBLIC_REIT:公募REITsRE_DIRECT_INVEST:投资性房产RE_PRIMARY:自住房(通常 Non‑investable)RE_OTHER:其他不动产
CMD / ALT / CRYPTO / OTHER(可选扩展)
建议在产品确实需要时再细分;原则是仍以“主要风险暴露”来定义子类。
3.4 维度D:工具类型(Vehicle Type,推荐)
工具类型回答“你通过什么载体持有这份暴露”,不参与主轴归类。
建议枚举(示例):
- 存款/现金(Deposit/Cash)
- 公募基金(Mutual Fund)
- ETF
- 股票(Stock)
- 债券(Bond)
- 银行理财(WMP)
- 公募REIT(REIT)
- 私募产品(Private)
- 保险(Insurance)
- 其他(Other)
3.5 核心/卫星(Core/Satellite,策略分层,配置层面必填)
核心/卫星是策略分层,不是资产大类。
- 核心(Core):长期持有、分散、低成本、规则更稳定(通常占比 70%–80%)
- 卫星(Satellite):小仓位增强收益或表达主题观点,波动更高、集中度更高(通常占比 20%–30%)
落地建议:
- Core 常见承载:
EQ_BROAD、高质量FI(利率/投资级) - Satellite 常见承载:
EQ_SECTOR_THEME、EQ_SINGLE_STOCK、更激进的ALT/CRYPTO
4. 常见概念如何“正确落位”(对齐示例)
你提到的条目在最佳实践体系中建议这样表达:
- 应急准备金:
CASH→CASH_EMERGENCY(Scope 可为 Investable,但通常不参与再平衡) - 固定收益类:资产大类
FI(子类按持仓实际暴露细分,如FI_FUND/FI_GOV等) - 应收账款:建议归入
CASH_RECEIVABLE或OTHER(并标记为 Non‑investable,避免干扰配置偏离) - 债券型基金:Exposure 归入
FI(子类可用FI_FUND),工具类型为 “公募基金” - 宽基指数:Exposure 归入
EQ_BROAD,工具类型可能是 ETF/指数基金
5. 数据录入与治理规则(强约束)
- 资产大类:必填,下拉选择(来自业务字典)
- 子资产类别:强烈建议必填,下拉选择(与资产大类联动过滤)
- 工具类型:推荐填写,下拉选择
- Scope:建议填写(Investable/Non‑investable)
- 允许扩展:仅通过“字典版本升级”增加新类;不允许自由输入新分类进入核心计算
6. 旧叫法映射(用于迁移/沟通)
以下映射仅用于把历史认知对齐到行业最佳实践主轴:
| 旧叫法 | 最佳实践落位 |
|---|---|
| 现金类 | CASH |
| 稳健类 | FI |
| 进阶类 | EQ |
| 核心资产 | Strategy Layer:Core |
| 卫星资产 | Strategy Layer:Satellite |